diff --git a/CodenameOne/src/com/codename1/ai/AnthropicClient.java b/CodenameOne/src/com/codename1/ai/AnthropicClient.java new file mode 100644 index 0000000000..6787914227 --- /dev/null +++ b/CodenameOne/src/com/codename1/ai/AnthropicClient.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ai; + +import com.codename1.util.AsyncResource; + +/// Anthropic /v1/messages client. Wire format differs from OpenAI in +/// three important ways: system messages live in a top-level `system` +/// string rather than a role; image parts use `{type:"image", source: +/// {type:"base64", media_type, data}}`; tool calls stream argument +/// JSON via `input_json_delta` events. +/// +/// This is currently a scaffold -- the full request/response mapping +/// is tracked as a follow-up. The class compiles and registers under +/// `LlmClient.anthropic(...)` so app code using the API can be built; +/// runtime calls throw a clear `UnsupportedOperationException`. +class AnthropicClient extends LlmClient { + private final String apiKey; + + AnthropicClient(String apiKey, String baseUrl) { + super(baseUrl); + this.apiKey = apiKey; + } + + public String getProvider() { + return "anthropic"; + } + + public AsyncResource chat(ChatRequest req) { + AsyncResource r = new AsyncResource(); + r.error(new UnsupportedOperationException( + "AnthropicClient is not yet implemented in this release. " + + "Use LlmClient.openai(...) or run the model behind an OpenAI-compatible proxy.")); + return r; + } + + public AsyncResource chatStream(ChatRequest req, StreamingListener listener) { + return chat(req); + } + + public AsyncResource embed(EmbeddingRequest req) { + AsyncResource r = new AsyncResource(); + r.error(new UnsupportedOperationException( + "Anthropic does not publish a first-party embeddings endpoint. " + + "Use a Voyage AI key via LlmClient.localOpenAiCompatible(\"https://api.voyageai.com/v1\", key, model).")); + return r; + } + + String getApiKey() { + return apiKey; + } +} diff --git a/CodenameOne/src/com/codename1/ai/ChatMessage.java b/CodenameOne/src/com/codename1/ai/ChatMessage.java new file mode 100644 index 0000000000..efb8b3d0cb --- /dev/null +++ b/CodenameOne/src/com/codename1/ai/ChatMessage.java @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ai; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/// A single turn in a chat conversation. Holds a [Role], one or more +/// [MessagePart]s, and (for assistant turns) any [ToolCall]s the model +/// produced. Construct via the static helpers ([#user(String)], +/// [#system(String)], etc.) for the common case, or pass parts +/// directly for multi-modal messages. +public final class ChatMessage { + private final Role role; + private final List parts; + private final List toolCalls; + private final String name; + private final String toolCallId; + + public ChatMessage(Role role, List parts) { + this(role, parts, null, null, null); + } + + public ChatMessage(Role role, List parts, List toolCalls, + String name, String toolCallId) { + if (role == null) { + throw new IllegalArgumentException("role is required"); + } + this.role = role; + this.parts = parts == null ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList(parts)); + this.toolCalls = toolCalls == null ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList(toolCalls)); + this.name = name; + this.toolCallId = toolCallId; + } + + public static ChatMessage system(String text) { + return single(Role.SYSTEM, new TextPart(text)); + } + + public static ChatMessage user(String text) { + return single(Role.USER, new TextPart(text)); + } + + public static ChatMessage assistant(String text) { + return single(Role.ASSISTANT, new TextPart(text)); + } + + /// Builds a USER message containing both a text and image part -- + /// the common multi-modal pattern. + public static ChatMessage userWithImage(String text, ImagePart image) { + List parts = new ArrayList(2); + if (text != null && text.length() > 0) { + parts.add(new TextPart(text)); + } + parts.add(image); + return new ChatMessage(Role.USER, parts); + } + + /// Builds a TOOL message wrapping the result of a previous tool call. + public static ChatMessage toolResult(String toolCallId, String resultJson) { + return new ChatMessage(Role.TOOL, + Arrays.asList(new ToolResultPart(toolCallId, resultJson)), + null, null, toolCallId); + } + + private static ChatMessage single(Role r, MessagePart p) { + List parts = new ArrayList(1); + parts.add(p); + return new ChatMessage(r, parts); + } + + public Role getRole() { + return role; + } + + public List getParts() { + return parts; + } + + public List getToolCalls() { + return toolCalls; + } + + public String getName() { + return name; + } + + public String getToolCallId() { + return toolCallId; + } + + /// Convenience: concatenates the text of every [TextPart]. Image + /// and tool-result parts are skipped. Useful for `ChatView` + /// rendering when you don't care about multi-modal content. + public String getText() { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < parts.size(); i++) { + MessagePart p = parts.get(i); + if (p instanceof TextPart) { + if (sb.length() > 0) { + sb.append('\n'); + } + sb.append(((TextPart) p).getText()); + } + } + return sb.toString(); + } +} diff --git a/CodenameOne/src/com/codename1/ai/ChatRequest.java b/CodenameOne/src/com/codename1/ai/ChatRequest.java new file mode 100644 index 0000000000..d6458e5a32 --- /dev/null +++ b/CodenameOne/src/com/codename1/ai/ChatRequest.java @@ -0,0 +1,231 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ai; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// The full request to [LlmClient#chat(ChatRequest)] / +/// [LlmClient#chatStream(ChatRequest, StreamingListener)]. Built via +/// [#builder()]; immutable once constructed so the same request can be +/// re-used across retries. +/// +/// Numeric tuning fields are boxed so a `null` means "don't send" -- +/// the provider's own default is used instead of one we picked. +public final class ChatRequest { + private final String model; + private final List messages; + private final Float temperature; + private final Integer maxTokens; + private final Float topP; + private final List stopSequences; + private final Long seed; + private final ResponseFormat responseFormat; + private final List tools; + private final ToolChoice toolChoice; + private final Map metadata; + private final SafetyFilter safetyFilter; + + private ChatRequest(Builder b) { + this.model = b.model; + this.messages = Collections.unmodifiableList(new ArrayList(b.messages)); + this.temperature = b.temperature; + this.maxTokens = b.maxTokens; + this.topP = b.topP; + this.stopSequences = b.stopSequences == null ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList(b.stopSequences)); + this.seed = b.seed; + this.responseFormat = b.responseFormat; + this.tools = b.tools == null ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList(b.tools)); + this.toolChoice = b.toolChoice; + this.metadata = b.metadata == null ? Collections.emptyMap() + : Collections.unmodifiableMap(new HashMap(b.metadata)); + this.safetyFilter = b.safetyFilter; + } + + public static Builder builder() { + return new Builder(); + } + + public String getModel() { + return model; + } + + public List getMessages() { + return messages; + } + + public Float getTemperature() { + return temperature; + } + + public Integer getMaxTokens() { + return maxTokens; + } + + public Float getTopP() { + return topP; + } + + public List getStopSequences() { + return stopSequences; + } + + public Long getSeed() { + return seed; + } + + public ResponseFormat getResponseFormat() { + return responseFormat; + } + + public List getTools() { + return tools; + } + + public ToolChoice getToolChoice() { + return toolChoice; + } + + public Map getMetadata() { + return metadata; + } + + public SafetyFilter getSafetyFilter() { + return safetyFilter; + } + + /// Returns a builder pre-populated with the values of this request. + /// Useful for replaying a request with one field changed. + public Builder toBuilder() { + Builder b = new Builder(); + b.model = model; + b.messages = new ArrayList(messages); + b.temperature = temperature; + b.maxTokens = maxTokens; + b.topP = topP; + b.stopSequences = new ArrayList(stopSequences); + b.seed = seed; + b.responseFormat = responseFormat; + b.tools = new ArrayList(tools); + b.toolChoice = toolChoice; + b.metadata = new HashMap(metadata); + b.safetyFilter = safetyFilter; + return b; + } + + public static final class Builder { + private String model; + private List messages = new ArrayList(); + private Float temperature; + private Integer maxTokens; + private Float topP; + private List stopSequences; + private Long seed; + private ResponseFormat responseFormat; + private List tools; + private ToolChoice toolChoice; + private Map metadata; + private SafetyFilter safetyFilter; + + Builder() { + } + + public Builder model(String model) { + this.model = model; + return this; + } + + public Builder messages(List messages) { + this.messages = messages == null ? new ArrayList() + : new ArrayList(messages); + return this; + } + + public Builder addMessage(ChatMessage m) { + this.messages.add(m); + return this; + } + + public Builder temperature(Float t) { + this.temperature = t; + return this; + } + + public Builder maxTokens(Integer n) { + this.maxTokens = n; + return this; + } + + public Builder topP(Float p) { + this.topP = p; + return this; + } + + public Builder stopSequences(List stops) { + this.stopSequences = stops; + return this; + } + + public Builder seed(Long seed) { + this.seed = seed; + return this; + } + + public Builder responseFormat(ResponseFormat f) { + this.responseFormat = f; + return this; + } + + public Builder tools(List tools) { + this.tools = tools; + return this; + } + + public Builder toolChoice(ToolChoice choice) { + this.toolChoice = choice; + return this; + } + + public Builder metadata(Map meta) { + this.metadata = meta; + return this; + } + + public Builder safetyFilter(SafetyFilter f) { + this.safetyFilter = f; + return this; + } + + public ChatRequest build() { + if (messages.isEmpty()) { + throw new IllegalStateException("at least one message is required"); + } + return new ChatRequest(this); + } + } +} diff --git a/CodenameOne/src/com/codename1/ai/ChatResponse.java b/CodenameOne/src/com/codename1/ai/ChatResponse.java new file mode 100644 index 0000000000..53f08263b0 --- /dev/null +++ b/CodenameOne/src/com/codename1/ai/ChatResponse.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ai; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/// The terminal response from a chat call. For streaming requests, the +/// `ChatResponse` carries the *aggregated* final assistant message -- +/// the individual deltas were delivered through [StreamingListener] +/// before this object was produced. +/// +/// `finishReason` is one of: `"stop"`, `"length"`, `"tool_calls"`, +/// `"content_filter"`, `"error"` (normalized across providers). +public final class ChatResponse { + private final ChatMessage assistantMessage; + private final List toolCalls; + private final String finishReason; + private final Usage usage; + private final String modelUsed; + + public ChatResponse(ChatMessage assistantMessage, List toolCalls, + String finishReason, Usage usage, String modelUsed) { + this.assistantMessage = assistantMessage; + this.toolCalls = toolCalls == null ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList(toolCalls)); + this.finishReason = finishReason; + this.usage = usage; + this.modelUsed = modelUsed; + } + + public ChatMessage getAssistantMessage() { + return assistantMessage; + } + + public List getToolCalls() { + return toolCalls; + } + + /// Convenience: the assembled assistant text. Equivalent to + /// `getAssistantMessage().getText()` when there is one. + public String getText() { + return assistantMessage == null ? "" : assistantMessage.getText(); + } + + public String getFinishReason() { + return finishReason; + } + + public Usage getUsage() { + return usage; + } + + public String getModelUsed() { + return modelUsed; + } +} diff --git a/CodenameOne/src/com/codename1/ai/ConversationStore.java b/CodenameOne/src/com/codename1/ai/ConversationStore.java new file mode 100644 index 0000000000..692b7a67b9 --- /dev/null +++ b/CodenameOne/src/com/codename1/ai/ConversationStore.java @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ai; + +import com.codename1.io.Storage; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// JSON-backed persistent conversation history. +/// +/// Stores [ChatMessage] lists in [Storage] under a caller-chosen key +/// so apps can rehydrate a `ChatView` after process restart. +/// Multimodal parts ([ImagePart], [ToolResultPart]) are serialized +/// to a lossy text fallback -- image data is not round-tripped (apps +/// that need full multimodal persistence should encode the bytes +/// themselves and keep them in [com.codename1.io.FileSystemStorage]). +public final class ConversationStore { + private static final String KIND_TEXT = "t"; + private static final String KIND_TOOL_RESULT = "tr"; + + private final String storageKey; + + public ConversationStore(String storageKey) { + if (storageKey == null || storageKey.length() == 0) { + throw new IllegalArgumentException("storageKey is required"); + } + this.storageKey = storageKey; + } + + public void save(List messages) throws IOException { + List serialized = new ArrayList(messages == null ? 0 : messages.size()); + if (messages != null) { + for (ChatMessage m : messages) { + Map jm = new HashMap(); + jm.put("role", m.getRole().name()); + List parts = new ArrayList(m.getParts().size()); + for (MessagePart p : m.getParts()) { + Map jp = new HashMap(); + if (p instanceof TextPart) { + jp.put("kind", KIND_TEXT); + jp.put("text", ((TextPart) p).getText()); + } else if (p instanceof ToolResultPart) { + ToolResultPart trp = (ToolResultPart) p; + jp.put("kind", KIND_TOOL_RESULT); + jp.put("toolCallId", trp.getToolCallId()); + jp.put("resultJson", trp.getResultJson()); + } else { + // ImagePart: lossy. Save a placeholder so + // the message order is preserved. + jp.put("kind", KIND_TEXT); + jp.put("text", "[image]"); + } + parts.add(jp); + } + jm.put("parts", parts); + if (m.getToolCallId() != null) { + jm.put("toolCallId", m.getToolCallId()); + } + serialized.add(jm); + } + } + Map root = new HashMap(); + root.put("messages", serialized); + byte[] payload = JsonHelper.serialize(root).getBytes("UTF-8"); + Storage.getInstance().writeObject(storageKey, payload); + } + + public List load() throws IOException { + Object raw = Storage.getInstance().readObject(storageKey); + if (raw == null) { + return new ArrayList(); + } + if (!(raw instanceof byte[])) { + // Old-format / accidental overwrite. Treat as empty + // rather than crashing. + return new ArrayList(); + } + Map root = JsonHelper.parseObject((byte[]) raw); + List serialized = JsonHelper.asList(root.get("messages")); + if (serialized == null) { + return new ArrayList(); + } + List out = new ArrayList(serialized.size()); + for (int i = 0; i < serialized.size(); i++) { + Map jm = JsonHelper.asMap(serialized.get(i)); + Role role = parseRole(JsonHelper.string(jm, "role")); + List parts = new ArrayList(); + List jparts = JsonHelper.asList(jm.get("parts")); + if (jparts != null) { + for (int j = 0; j < jparts.size(); j++) { + Map jp = JsonHelper.asMap(jparts.get(j)); + String kind = JsonHelper.string(jp, "kind"); + if (KIND_TOOL_RESULT.equals(kind)) { + parts.add(new ToolResultPart( + JsonHelper.string(jp, "toolCallId"), + JsonHelper.string(jp, "resultJson"))); + } else { + parts.add(new TextPart(JsonHelper.string(jp, "text"))); + } + } + } + out.add(new ChatMessage(role, parts, + null, null, JsonHelper.string(jm, "toolCallId"))); + } + return out; + } + + public void clear() { + Storage.getInstance().deleteStorageFile(storageKey); + } + + public String getStorageKey() { + return storageKey; + } + + private static Role parseRole(String name) { + if (name == null) return Role.USER; + try { + return Role.valueOf(name); + } catch (IllegalArgumentException iae) { + return Role.USER; + } + } + + // Suppress unused-import warning for Arrays in some toolchains. + @SuppressWarnings("unused") + private static final Object[] UNUSED = new Object[]{Arrays.class}; +} diff --git a/CodenameOne/src/com/codename1/ai/Embedding.java b/CodenameOne/src/com/codename1/ai/Embedding.java new file mode 100644 index 0000000000..bb49091a2b --- /dev/null +++ b/CodenameOne/src/com/codename1/ai/Embedding.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ai; + +/// A single embedding vector. `index` matches the position of the +/// corresponding input string in the original request. +public final class Embedding { + private final float[] vector; + private final int index; + + public Embedding(float[] vector, int index) { + this.vector = vector == null ? new float[0] : vector; + this.index = index; + } + + public float[] getVector() { + return vector; + } + + public int getIndex() { + return index; + } + + public int getDimensions() { + return vector.length; + } +} diff --git a/CodenameOne/src/com/codename1/ai/EmbeddingRequest.java b/CodenameOne/src/com/codename1/ai/EmbeddingRequest.java new file mode 100644 index 0000000000..d8cf2b7e0b --- /dev/null +++ b/CodenameOne/src/com/codename1/ai/EmbeddingRequest.java @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ai; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/// Request payload for [LlmClient#embed(EmbeddingRequest)]. Carries +/// one or more input strings plus optional `dimensions` (OpenAI's +/// `dimensions`, Gemini's `outputDimensionality`). A `null` value means +/// "use the model's default dimensionality". +public final class EmbeddingRequest { + private final String model; + private final List inputs; + private final Integer dimensions; + + private EmbeddingRequest(Builder b) { + this.model = b.model; + this.inputs = Collections.unmodifiableList(new ArrayList(b.inputs)); + this.dimensions = b.dimensions; + } + + public static Builder builder() { + return new Builder(); + } + + /// Convenience for the single-input case. + public static EmbeddingRequest of(String model, String text) { + return builder().model(model).inputs(Arrays.asList(text)).build(); + } + + public String getModel() { + return model; + } + + public List getInputs() { + return inputs; + } + + public Integer getDimensions() { + return dimensions; + } + + public static final class Builder { + private String model; + private List inputs = new ArrayList(); + private Integer dimensions; + + Builder() { + } + + public Builder model(String m) { + this.model = m; + return this; + } + + public Builder inputs(List in) { + this.inputs = in == null ? new ArrayList() + : new ArrayList(in); + return this; + } + + public Builder addInput(String s) { + this.inputs.add(s); + return this; + } + + public Builder dimensions(Integer d) { + this.dimensions = d; + return this; + } + + public EmbeddingRequest build() { + if (inputs.isEmpty()) { + throw new IllegalStateException("at least one input is required"); + } + return new EmbeddingRequest(this); + } + } +} diff --git a/CodenameOne/src/com/codename1/ai/EmbeddingResponse.java b/CodenameOne/src/com/codename1/ai/EmbeddingResponse.java new file mode 100644 index 0000000000..d2553a48e9 --- /dev/null +++ b/CodenameOne/src/com/codename1/ai/EmbeddingResponse.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ai; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public final class EmbeddingResponse { + private final List data; + private final Usage usage; + private final String modelUsed; + + public EmbeddingResponse(List data, Usage usage, String modelUsed) { + this.data = data == null ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList(data)); + this.usage = usage; + this.modelUsed = modelUsed; + } + + public List getData() { + return data; + } + + public Usage getUsage() { + return usage; + } + + public String getModelUsed() { + return modelUsed; + } +} diff --git a/CodenameOne/src/com/codename1/ai/GeminiClient.java b/CodenameOne/src/com/codename1/ai/GeminiClient.java new file mode 100644 index 0000000000..43311c7bcc --- /dev/null +++ b/CodenameOne/src/com/codename1/ai/GeminiClient.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ai; + +import com.codename1.util.AsyncResource; + +/// Google Gemini client. The native wire format diverges from OpenAI's: +/// system messages live in `systemInstruction`, content is split into +/// `parts` with `inline_data` / `text`, tool calls arrive atomically +/// at stream end rather than fragment-by-fragment. +/// +/// Google publishes an OpenAI-compatibility endpoint at +/// `https://generativelanguage.googleapis.com/v1beta/openai/` that +/// works with [LlmClient#localOpenAiCompatible] today; this dedicated +/// client (which handles the native shape end-to-end) is a follow-up. +class GeminiClient extends LlmClient { + private final String apiKey; + + GeminiClient(String apiKey, String baseUrl) { + super(baseUrl); + this.apiKey = apiKey; + } + + public String getProvider() { + return "gemini"; + } + + public AsyncResource chat(ChatRequest req) { + AsyncResource r = new AsyncResource(); + r.error(new UnsupportedOperationException( + "GeminiClient (native) is not yet implemented in this release. " + + "Use LlmClient.localOpenAiCompatible(" + + "\"https://generativelanguage.googleapis.com/v1beta/openai\", apiKey, model) " + + "to reach Gemini through Google's OpenAI-compatible shim.")); + return r; + } + + public AsyncResource chatStream(ChatRequest req, StreamingListener listener) { + return chat(req); + } + + public AsyncResource embed(EmbeddingRequest req) { + AsyncResource r = new AsyncResource(); + r.error(new UnsupportedOperationException( + "GeminiClient.embed is not yet implemented. Use the OpenAI-compatible shim " + + "or LlmClient.openai(...) with text-embedding-3-small.")); + return r; + } + + String getApiKey() { + return apiKey; + } +} diff --git a/CodenameOne/src/com/codename1/ai/GenerateImageRequest.java b/CodenameOne/src/com/codename1/ai/GenerateImageRequest.java new file mode 100644 index 0000000000..1f308e137e --- /dev/null +++ b/CodenameOne/src/com/codename1/ai/GenerateImageRequest.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ai; + +/// Request payload for [ImageGenerator#generate(GenerateImageRequest)]. +public final class GenerateImageRequest { + private final String prompt; + private String model; + private String size = "1024x1024"; + private String style; + private String quality; + private int count = 1; + private Long seed; + + public GenerateImageRequest(String prompt) { + if (prompt == null || prompt.length() == 0) { + throw new IllegalArgumentException("prompt is required"); + } + this.prompt = prompt; + } + + public String getPrompt() { + return prompt; + } + + public String getModel() { + return model; + } + + public GenerateImageRequest setModel(String model) { + this.model = model; + return this; + } + + public String getSize() { + return size; + } + + /// `"1024x1024"`, `"1024x1792"`, `"1792x1024"` for DALL-E 3. + /// Default is `"1024x1024"`. + public GenerateImageRequest setSize(String size) { + this.size = size; + return this; + } + + public String getStyle() { + return style; + } + + public GenerateImageRequest setStyle(String style) { + this.style = style; + return this; + } + + public String getQuality() { + return quality; + } + + public GenerateImageRequest setQuality(String quality) { + this.quality = quality; + return this; + } + + public int getCount() { + return count; + } + + /// Number of images to generate (DALL-E 3 supports 1; older + /// models up to 10). + public GenerateImageRequest setCount(int count) { + this.count = Math.max(1, count); + return this; + } + + public Long getSeed() { + return seed; + } + + public GenerateImageRequest setSeed(Long seed) { + this.seed = seed; + return this; + } +} diff --git a/CodenameOne/src/com/codename1/ai/ImageGenerator.java b/CodenameOne/src/com/codename1/ai/ImageGenerator.java new file mode 100644 index 0000000000..7ee73a193a --- /dev/null +++ b/CodenameOne/src/com/codename1/ai/ImageGenerator.java @@ -0,0 +1,212 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ai; + +import com.codename1.io.ConnectionRequest; +import com.codename1.io.NetworkManager; +import com.codename1.ui.Display; +import com.codename1.ui.Image; +import com.codename1.util.AsyncResource; +import com.codename1.util.Base64; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// Cloud-first image generation. The `openai` and `replicate` factory +/// methods cover the two dominant managed endpoints. On-device +/// generation via Core ML / ONNX Stable Diffusion is provided +/// separately by the optional `cn1-ai-stablediffusion` cn1lib; see +/// [#onDevice()]. +/// +/// ``` +/// ImageGenerator.openai(KeyStore.get("openai_key")) +/// .generate(new GenerateImageRequest("A cat in a sombrero").setSize("1024x1024")) +/// .ready(img -> imageComponent.setIcon(img)); +/// ``` +public abstract class ImageGenerator { + + public static ImageGenerator openai(String apiKey) { + return new OpenAiImageGenerator(apiKey); + } + + /// Replicate runs a wide catalog of third-party image models + /// (SDXL, Flux, etc.) behind a uniform REST API. Pass the API + /// token from `https://replicate.com/account`. + public static ImageGenerator replicate(String apiKey) { + return new ReplicateImageGenerator(apiKey); + } + + /// On-device generator. Requires the optional cn1lib + /// `cn1-ai-stablediffusion`; without it this returns an + /// `AsyncResource` that completes with + /// `UnsupportedOperationException`. + public static ImageGenerator onDevice() { + // Lazy lookup so app code can compile even without the + // cn1lib. The cn1lib registers an implementation via + // `NativeLookup.register(...)` when it ships, but for the + // base framework we just return a no-op stub. + return new ImageGenerator() { + public AsyncResource generate(GenerateImageRequest req) { + AsyncResource out = new AsyncResource(); + out.error(new UnsupportedOperationException( + "On-device image generation requires the cn1-ai-stablediffusion cn1lib. " + + "Add it to your dependencies or use ImageGenerator.openai(...) instead.")); + return out; + } + }; + } + + public abstract AsyncResource generate(GenerateImageRequest req); + + // --------------------- OpenAI --------------------- + + private static final class OpenAiImageGenerator extends ImageGenerator { + private final String apiKey; + + OpenAiImageGenerator(String apiKey) { + this.apiKey = apiKey == null ? "" : apiKey; + } + + public AsyncResource generate(GenerateImageRequest req) { + final AsyncResource result = new AsyncResource(); + final byte[] body; + try { + Map root = new HashMap(); + root.put("model", req.getModel() != null ? req.getModel() : "dall-e-3"); + root.put("prompt", req.getPrompt()); + root.put("n", Integer.valueOf(req.getCount())); + root.put("size", req.getSize()); + if (req.getStyle() != null) root.put("style", req.getStyle()); + if (req.getQuality() != null) root.put("quality", req.getQuality()); + // b64_json keeps the request self-contained; the + // alternative `url` requires a second fetch and + // expires after an hour, which is hostile to caching. + root.put("response_format", "b64_json"); + body = JsonHelper.serialize(root).getBytes("UTF-8"); + } catch (IOException ioe) { + result.error(ioe); + return result; + } + ConnectionRequest cr = new ConnectionRequest() { + @Override + protected void buildRequestBody(OutputStream os) throws IOException { + os.write(body); + } + + @Override + protected void handleErrorResponseCode(int code, String message) { + } + + @Override + protected void postResponse() { + try { + Map root = JsonHelper.parseObject(getResponseData()); + List data = JsonHelper.asList(root.get("data")); + if (data == null || data.isEmpty()) { + failOnEdt(result, new LlmInvalidRequestException( + "Empty data[] in image generation response", 200, null, null)); + return; + } + Map first = JsonHelper.asMap(data.get(0)); + String b64 = JsonHelper.string(first, "b64_json"); + if (b64 == null) { + failOnEdt(result, new LlmInvalidRequestException( + "Missing b64_json in image generation response", 200, null, null)); + return; + } + byte[] bytes = Base64.decode(b64.getBytes("UTF-8")); + final Image img = Image.createImage(bytes, 0, bytes.length); + Display.getInstance().callSerially(new Runnable() { + public void run() { + result.complete(img); + } + }); + } catch (Exception ex) { + failOnEdt(result, new LlmException("Failed to decode image", ex)); + } + } + + @Override + protected void handleException(Exception err) { + int sc; + try { + sc = getResponseCode(); + } catch (Throwable ignore) { + sc = -1; + } + String bodyText = ""; + try { + byte[] d = getResponseData(); + bodyText = d == null ? "" : new String(d, "UTF-8"); + } catch (Exception ignored) { + } + failOnEdt(result, OpenAiSseDecoder.mapErrorStatic(sc, bodyText)); + } + }; + cr.setUrl("https://api.openai.com/v1/images/generations"); + cr.setPost(true); + cr.setReadResponseForErrors(true); + cr.setDuplicateSupported(true); + cr.setContentType("application/json"); + cr.setTimeout(120000); + if (apiKey.length() > 0) { + cr.addRequestHeader("Authorization", "Bearer " + apiKey); + } + NetworkManager.getInstance().addToQueue(cr); + return result; + } + } + + // --------------------- Replicate --------------------- + + private static final class ReplicateImageGenerator extends ImageGenerator { + private final String apiKey; + + ReplicateImageGenerator(String apiKey) { + this.apiKey = apiKey == null ? "" : apiKey; + } + + public AsyncResource generate(GenerateImageRequest req) { + AsyncResource result = new AsyncResource(); + // Replicate's "predictions" API is async/polled; full + // long-poll support is a follow-up. For now we surface a + // clear error so callers know to use a self-hosted + // Replicate-compatible endpoint or the OpenAI path. + result.error(new UnsupportedOperationException( + "Replicate's prediction API requires long-polling that is not implemented yet. " + + "Use ImageGenerator.openai(...) or run a Replicate-compatible server behind LlmClient.localOpenAiCompatible(...).")); + return result; + } + } + + private static void failOnEdt(final AsyncResource result, final Throwable t) { + Display.getInstance().callSerially(new Runnable() { + public void run() { + result.error(t); + } + }); + } +} diff --git a/CodenameOne/src/com/codename1/ai/ImagePart.java b/CodenameOne/src/com/codename1/ai/ImagePart.java new file mode 100644 index 0000000000..df8a81e042 --- /dev/null +++ b/CodenameOne/src/com/codename1/ai/ImagePart.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ai; + +/// An image attachment for a multi-modal [ChatMessage]. Construct from +/// raw bytes (the provider encodes them as base64 inline data) or from +/// a publicly-reachable URL -- both modes are accepted by OpenAI, +/// Anthropic, and Gemini. +public final class ImagePart extends MessagePart { + private final byte[] data; + private final String mimeType; + private final String url; + + /// Inline image bytes. `mimeType` must be set (e.g. `"image/png"`, + /// `"image/jpeg"`); the providers reject inline images without it. + public ImagePart(byte[] data, String mimeType) { + if (data == null || mimeType == null) { + throw new IllegalArgumentException("data and mimeType are required"); + } + this.data = data; + this.mimeType = mimeType; + this.url = null; + } + + /// Remote image by URL. Only HTTPS is portable across providers. + public ImagePart(String url) { + if (url == null) { + throw new IllegalArgumentException("url is required"); + } + this.data = null; + this.mimeType = null; + this.url = url; + } + + public byte[] getData() { + return data; + } + + public String getMimeType() { + return mimeType; + } + + public String getUrl() { + return url; + } + + public boolean isUrl() { + return url != null; + } +} diff --git a/CodenameOne/src/com/codename1/ai/JsonHelper.java b/CodenameOne/src/com/codename1/ai/JsonHelper.java new file mode 100644 index 0000000000..2ef87d5dac --- /dev/null +++ b/CodenameOne/src/com/codename1/ai/JsonHelper.java @@ -0,0 +1,259 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ai; + +import com.codename1.io.JSONParser; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.Reader; +import java.util.List; +import java.util.Map; + +/// Internal helper. Concentrates the unchecked casts that +/// `JSONParser`'s `Hashtable`-era return types force on us, and +/// provides a tiny `Map`/`List`-to-JSON serializer so request bodies +/// can be built without bringing in a third-party JSON library. +final class JsonHelper { + + private JsonHelper() { + } + + static Map parseObject(String json) throws IOException { + return parseObject(json.getBytes("UTF-8")); + } + + static Map parseObject(byte[] bytes) throws IOException { + Reader r = new InputStreamReader(new ByteArrayInputStream(bytes), "UTF-8"); + try { + return new JSONParser().parseJSON(r); + } finally { + try { + r.close(); + } catch (IOException ignored) { + } + } + } + + @SuppressWarnings("unchecked") + static Map asMap(Object o) { + if (o == null) { + return null; + } + return (Map) o; + } + + @SuppressWarnings("unchecked") + static List asList(Object o) { + if (o == null) { + return null; + } + return (List) o; + } + + static String string(Map m, String key) { + if (m == null) { + return null; + } + Object v = m.get(key); + return v == null ? null : v.toString(); + } + + static int intValue(Map m, String key, int defaultValue) { + if (m == null) { + return defaultValue; + } + Object v = m.get(key); + if (v == null) { + return defaultValue; + } + if (v instanceof Number) { + return ((Number) v).intValue(); + } + try { + return Integer.parseInt(v.toString()); + } catch (NumberFormatException nfe) { + return defaultValue; + } + } + + static double doubleValue(Map m, String key, double defaultValue) { + if (m == null) { + return defaultValue; + } + Object v = m.get(key); + if (v == null) { + return defaultValue; + } + if (v instanceof Number) { + return ((Number) v).doubleValue(); + } + try { + return Double.parseDouble(v.toString()); + } catch (NumberFormatException nfe) { + return defaultValue; + } + } + + /// Serializes a `Map`/`List`/`String`/`Number`/`Boolean`/`null` + /// tree to JSON. Keys must be `String`s. Raw `String` values that + /// already contain JSON are NOT detected -- wrap them in a marker + /// (see [RawJson]) to inline pre-built fragments like tool + /// parameter schemas. + static String serialize(Object o) { + StringBuilder sb = new StringBuilder(); + writeValue(sb, o); + return sb.toString(); + } + + private static void writeValue(StringBuilder sb, Object o) { + if (o == null) { + sb.append("null"); + return; + } + if (o instanceof RawJson) { + String s = ((RawJson) o).getJson(); + sb.append(s == null || s.length() == 0 ? "null" : s); + return; + } + if (o instanceof String) { + writeString(sb, (String) o); + return; + } + if (o instanceof Boolean) { + sb.append(((Boolean) o).booleanValue() ? "true" : "false"); + return; + } + if (o instanceof Number) { + // Avoid 1.0E10 etc. when an integer fits; otherwise the + // provider may reject the body. + Number n = (Number) o; + if (n instanceof Float || n instanceof Double) { + double d = n.doubleValue(); + if (Double.isInfinite(d) || Double.isNaN(d)) { + sb.append("null"); + } else { + sb.append(d); + } + } else { + sb.append(n.longValue()); + } + return; + } + if (o instanceof Map) { + sb.append('{'); + boolean first = true; + Map m = (Map) o; + for (Object kObj : m.keySet()) { + Object v = m.get(kObj); + if (v == null) { + // Skip nulls so "don't send" fields don't appear + // as `"field":null` on the wire -- many providers + // reject that. + continue; + } + if (!first) { + sb.append(','); + } + first = false; + writeString(sb, kObj.toString()); + sb.append(':'); + writeValue(sb, v); + } + sb.append('}'); + return; + } + if (o instanceof List) { + sb.append('['); + List l = (List) o; + for (int i = 0; i < l.size(); i++) { + if (i > 0) { + sb.append(','); + } + writeValue(sb, l.get(i)); + } + sb.append(']'); + return; + } + // Last resort: stringify. + writeString(sb, o.toString()); + } + + private static void writeString(StringBuilder sb, String s) { + sb.append('"'); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + switch (c) { + case '"': + sb.append("\\\""); + break; + case '\\': + sb.append("\\\\"); + break; + case '\b': + sb.append("\\b"); + break; + case '\f': + sb.append("\\f"); + break; + case '\n': + sb.append("\\n"); + break; + case '\r': + sb.append("\\r"); + break; + case '\t': + sb.append("\\t"); + break; + default: + if (c < 0x20) { + sb.append("\\u"); + String h = Integer.toHexString(c); + for (int p = h.length(); p < 4; p++) { + sb.append('0'); + } + sb.append(h); + } else { + sb.append(c); + } + } + } + sb.append('"'); + } + + /// Marker that lets a caller embed a pre-built JSON fragment + /// (e.g. a tool's `parametersJsonSchema`) into a Map tree without + /// having it re-escaped as a string. + static final class RawJson { + private final String json; + + RawJson(String json) { + this.json = json; + } + + String getJson() { + return json; + } + } +} diff --git a/CodenameOne/src/com/codename1/ai/LlmAuthException.java b/CodenameOne/src/com/codename1/ai/LlmAuthException.java new file mode 100644 index 0000000000..cdf188d4c1 --- /dev/null +++ b/CodenameOne/src/com/codename1/ai/LlmAuthException.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ai; + +/// 401/403: invalid or revoked API key, or insufficient permissions +/// for the requested model. +public class LlmAuthException extends LlmException { + public LlmAuthException(String message, int httpStatus, String code, String rawBody) { + super(message, httpStatus, code, rawBody, null); + } +} diff --git a/CodenameOne/src/com/codename1/ai/LlmClient.java b/CodenameOne/src/com/codename1/ai/LlmClient.java new file mode 100644 index 0000000000..ffb862c72f --- /dev/null +++ b/CodenameOne/src/com/codename1/ai/LlmClient.java @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ai; + +import com.codename1.util.AsyncResource; + +/// Provider-agnostic chat / embeddings client. Built via one of the +/// static factory methods: +/// +/// ``` +/// LlmClient gpt = LlmClient.openai("sk-..."); +/// LlmClient claude = LlmClient.anthropic("sk-ant-..."); +/// LlmClient gemini = LlmClient.gemini("AIza..."); +/// LlmClient ollama = LlmClient.ollama(); // localhost:11434 +/// LlmClient local = LlmClient.localOpenAiCompatible( +/// "http://10.0.0.5:8080/v1", "", "qwen2.5-7b"); +/// ``` +/// +/// All calls return [AsyncResource] so they compose naturally with the +/// rest of the Codename One async API. `chatStream` additionally fires +/// per-token deltas through a [StreamingListener]; both that listener +/// and the final `AsyncResource` complete on the EDT. +/// +/// #### Simulator behaviour +/// +/// When running in the JavaSE simulator with `cn1.ai.simulatorRedirect` +/// set to `ollama` (or `auto` with Ollama detected on the loopback), +/// the static factories transparently route through a local Ollama +/// endpoint instead of the public provider -- so unchanged production +/// code can be debugged offline without API charges. +public abstract class LlmClient { + private String baseUrl; + private int httpTimeoutMs = 60000; + + protected LlmClient(String baseUrl) { + this.baseUrl = baseUrl; + } + + /// OpenAI / OpenAI-compatible (Together, Groq, Fireworks, vLLM, + /// Ollama, etc.). Uses the public endpoint by default; override + /// with [#setBaseUrl(String)]. + public static LlmClient openai(String apiKey) { + return SimulatorRedirect.maybeWrap(new OpenAiClient(apiKey, + "https://api.openai.com/v1")); + } + + public static LlmClient anthropic(String apiKey) { + return SimulatorRedirect.maybeWrap(new AnthropicClient(apiKey, + "https://api.anthropic.com/v1")); + } + + public static LlmClient gemini(String apiKey) { + return SimulatorRedirect.maybeWrap(new GeminiClient(apiKey, + "https://generativelanguage.googleapis.com/v1beta")); + } + + /// Default Ollama install: `http://localhost:11434/v1`, model + /// `llama3.2`. + public static LlmClient ollama() { + return ollama("llama3.2"); + } + + public static LlmClient ollama(String defaultModel) { + return ollama("http://localhost:11434/v1", defaultModel); + } + + public static LlmClient ollama(String baseUrl, String defaultModel) { + OpenAiClient c = new OpenAiClient("ollama", baseUrl); + c.setDefaultModel(defaultModel); + return c; + } + + /// Generic OpenAI-compatible endpoint (llama.cpp server, vLLM, + /// LM Studio, a custom proxy). `apiKey` may be empty for local + /// services that don't authenticate. + public static LlmClient localOpenAiCompatible(String baseUrl, String apiKey, String defaultModel) { + OpenAiClient c = new OpenAiClient(apiKey == null ? "" : apiKey, baseUrl); + c.setDefaultModel(defaultModel); + return c; + } + + /// Non-streaming chat. Equivalent to `chatStream` with a no-op + /// listener but optimized -- the provider skips the SSE response + /// and returns a single JSON object. + public abstract AsyncResource chat(ChatRequest req); + + /// Streaming chat. `listener` fires for every content delta / + /// tool-call fragment on the EDT. The returned `AsyncResource` + /// completes with the aggregated final response once the stream + /// ends; cancel it to close the underlying socket. + public abstract AsyncResource chatStream(ChatRequest req, StreamingListener listener); + + public abstract AsyncResource embed(EmbeddingRequest req); + + /// One of `"openai"`, `"anthropic"`, `"gemini"`, `"ollama"`, + /// `"local"`. Used by `ChatView` and tests to vary behaviour by + /// provider. + public abstract String getProvider(); + + public String getBaseUrl() { + return baseUrl; + } + + public void setBaseUrl(String baseUrl) { + this.baseUrl = baseUrl; + } + + public int getHttpTimeoutMs() { + return httpTimeoutMs; + } + + public void setHttpTimeoutMs(int httpTimeoutMs) { + this.httpTimeoutMs = httpTimeoutMs; + } + + /// Helper for subclasses: applies the active [SafetyFilter] (if + /// any) before the network call. Returns the rejection reason on + /// failure, `null` to proceed. + protected String runSafetyFilter(ChatRequest req) { + if (req.getSafetyFilter() == null) { + return null; + } + return req.getSafetyFilter().check(req.getMessages()); + } +} diff --git a/CodenameOne/src/com/codename1/ai/LlmContextLengthException.java b/CodenameOne/src/com/codename1/ai/LlmContextLengthException.java new file mode 100644 index 0000000000..5996bb65a0 --- /dev/null +++ b/CodenameOne/src/com/codename1/ai/LlmContextLengthException.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ai; + +/// A specific 400 subtype: the conversation exceeded the model's +/// context window. Drop older messages and retry, or switch to a +/// larger-context model. +public class LlmContextLengthException extends LlmInvalidRequestException { + public LlmContextLengthException(String message, String code, String rawBody) { + super(message, 400, code, rawBody); + } +} diff --git a/CodenameOne/src/com/codename1/ai/LlmException.java b/CodenameOne/src/com/codename1/ai/LlmException.java new file mode 100644 index 0000000000..2fee89c45a --- /dev/null +++ b/CodenameOne/src/com/codename1/ai/LlmException.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ai; + +import java.io.IOException; + +/// Base type for every checked error raised by [LlmClient]. Extends +/// [IOException] so callers' existing network catch blocks pick it up. +/// Inspect the subtype (e.g. [LlmRateLimitException]) for actionable +/// detail; `httpStatus`, `providerErrorCode`, and `rawBody` are +/// populated when available. +public class LlmException extends IOException { + private final int httpStatus; + private final String providerErrorCode; + private final String rawBody; + + public LlmException(String message) { + this(message, -1, null, null, null); + } + + public LlmException(String message, Throwable cause) { + this(message, -1, null, null, cause); + } + + public LlmException(String message, int httpStatus, String providerErrorCode, + String rawBody, Throwable cause) { + super(message); + if (cause != null) { + initCause(cause); + } + this.httpStatus = httpStatus; + this.providerErrorCode = providerErrorCode; + this.rawBody = rawBody; + } + + public int getHttpStatus() { + return httpStatus; + } + + public String getProviderErrorCode() { + return providerErrorCode; + } + + public String getRawBody() { + return rawBody; + } +} diff --git a/CodenameOne/src/com/codename1/ai/LlmInvalidRequestException.java b/CodenameOne/src/com/codename1/ai/LlmInvalidRequestException.java new file mode 100644 index 0000000000..787bc1b1a9 --- /dev/null +++ b/CodenameOne/src/com/codename1/ai/LlmInvalidRequestException.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ai; + +/// 400/422: malformed request payload, unsupported parameter, image +/// too large, etc. +public class LlmInvalidRequestException extends LlmException { + public LlmInvalidRequestException(String message, int httpStatus, String code, String rawBody) { + super(message, httpStatus, code, rawBody, null); + } +} diff --git a/CodenameOne/src/com/codename1/ai/LlmModelOverloadedException.java b/CodenameOne/src/com/codename1/ai/LlmModelOverloadedException.java new file mode 100644 index 0000000000..8703a33c35 --- /dev/null +++ b/CodenameOne/src/com/codename1/ai/LlmModelOverloadedException.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ai; + +/// 503 / 529: the model is temporarily overloaded. Treat the same as +/// rate-limiting and back off. +public class LlmModelOverloadedException extends LlmException { + public LlmModelOverloadedException(String message, int httpStatus, String code, String rawBody) { + super(message, httpStatus, code, rawBody, null); + } +} diff --git a/CodenameOne/src/com/codename1/ai/LlmNetworkException.java b/CodenameOne/src/com/codename1/ai/LlmNetworkException.java new file mode 100644 index 0000000000..678f051cab --- /dev/null +++ b/CodenameOne/src/com/codename1/ai/LlmNetworkException.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ai; + +/// Wraps a lower-level transport failure (DNS, TLS, read timeout, +/// connection reset). Carries no HTTP status by definition. +public class LlmNetworkException extends LlmException { + public LlmNetworkException(String message, Throwable cause) { + super(message, -1, null, null, cause); + } +} diff --git a/CodenameOne/src/com/codename1/ai/LlmRateLimitException.java b/CodenameOne/src/com/codename1/ai/LlmRateLimitException.java new file mode 100644 index 0000000000..99dc154e66 --- /dev/null +++ b/CodenameOne/src/com/codename1/ai/LlmRateLimitException.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ai; + +/// 429: rate limit hit. `retryAfterSeconds` is `-1` when the provider +/// didn't send a `Retry-After` header -- pick your own backoff in that +/// case (`RetryPolicy.exponentialBackoff()` is the default). +public class LlmRateLimitException extends LlmException { + private final int retryAfterSeconds; + + public LlmRateLimitException(String message, int retryAfterSeconds, String code, String rawBody) { + super(message, 429, code, rawBody, null); + this.retryAfterSeconds = retryAfterSeconds; + } + + public int getRetryAfterSeconds() { + return retryAfterSeconds; + } +} diff --git a/CodenameOne/src/com/codename1/ai/LlmServerException.java b/CodenameOne/src/com/codename1/ai/LlmServerException.java new file mode 100644 index 0000000000..9965fb2f2f --- /dev/null +++ b/CodenameOne/src/com/codename1/ai/LlmServerException.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ai; + +/// 5xx other than 503/529: provider had an internal error. +public class LlmServerException extends LlmException { + public LlmServerException(String message, int httpStatus, String code, String rawBody) { + super(message, httpStatus, code, rawBody, null); + } +} diff --git a/CodenameOne/src/com/codename1/ai/MessagePart.java b/CodenameOne/src/com/codename1/ai/MessagePart.java new file mode 100644 index 0000000000..868e685cd5 --- /dev/null +++ b/CodenameOne/src/com/codename1/ai/MessagePart.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ai; + +/// A single content fragment within a [ChatMessage]. Concrete subclasses +/// are [TextPart], [ImagePart], and [ToolResultPart]. Each provider +/// client translates parts to its own wire schema (OpenAI/Anthropic use +/// content arrays; Gemini uses `parts` with `inline_data` / `text`). +public abstract class MessagePart { + MessagePart() { + // package-private -- instantiate via concrete subclass + } +} diff --git a/CodenameOne/src/com/codename1/ai/OpenAiClient.java b/CodenameOne/src/com/codename1/ai/OpenAiClient.java new file mode 100644 index 0000000000..5ff1950f3d --- /dev/null +++ b/CodenameOne/src/com/codename1/ai/OpenAiClient.java @@ -0,0 +1,438 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ai; + +import com.codename1.io.ConnectionRequest; +import com.codename1.io.NetworkManager; +import com.codename1.ui.Display; +import com.codename1.util.AsyncResource; + +import java.io.IOException; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/// OpenAI-compatible chat / embeddings client. Drives Ollama, +/// llama.cpp, vLLM, Together, and any other endpoint that speaks the +/// `/v1/chat/completions` + `/v1/embeddings` shape. +class OpenAiClient extends LlmClient { + private final String apiKey; + private String defaultModel = "gpt-4o-mini"; + + OpenAiClient(String apiKey, String baseUrl) { + super(baseUrl); + this.apiKey = apiKey == null ? "" : apiKey; + } + + void setDefaultModel(String m) { + this.defaultModel = m; + } + + public String getProvider() { + return "openai"; + } + + public AsyncResource chat(ChatRequest req) { + final AsyncResource result = new AsyncResource(); + String reject = runSafetyFilter(req); + if (reject != null) { + result.error(new LlmInvalidRequestException("Blocked by safety filter: " + reject, + 400, "safety_filter", null)); + return result; + } + final byte[] body; + try { + body = buildChatBody(req, false); + } catch (IOException ioe) { + result.error(ioe); + return result; + } + ConnectionRequest cr = new ConnectionRequest() { + @Override + protected void buildRequestBody(OutputStream os) throws IOException { + os.write(body); + } + + @Override + protected void handleErrorResponseCode(int code, String message) { + // Suppress framework's default dialog -- we'll deliver + // an exception through the AsyncResource instead. + } + + @Override + protected void postResponse() { + final byte[] data = getResponseData(); + try { + Map root = JsonHelper.parseObject(data); + final ChatResponse cr2 = OpenAiSseDecoder.parseNonStreaming(root); + Display.getInstance().callSerially(new Runnable() { + public void run() { + result.complete(cr2); + } + }); + } catch (Exception exc) { + final Exception ex = exc; + Display.getInstance().callSerially(new Runnable() { + public void run() { + result.error(new LlmException("Failed to parse response", ex)); + } + }); + } + } + + @Override + protected void handleException(Exception errIn) { + final Exception err = errIn; + final int sc; + try { + sc = getResponseCode(); + } catch (Throwable ignore) { + Display.getInstance().callSerially(new Runnable() { + public void run() { + result.error(new LlmNetworkException(err.getMessage(), err)); + } + }); + return; + } + final String bodyText; + try { + byte[] d = getResponseData(); + bodyText = d == null ? "" : new String(d, "UTF-8"); + } catch (Exception ex) { + Display.getInstance().callSerially(new Runnable() { + public void run() { + result.error(new LlmNetworkException(err.getMessage(), err)); + } + }); + return; + } + final LlmException mapped = OpenAiSseDecoder.mapErrorStatic(sc, bodyText); + Display.getInstance().callSerially(new Runnable() { + public void run() { + result.error(mapped); + } + }); + } + }; + configureRequest(cr, "/chat/completions"); + NetworkManager.getInstance().addToQueue(cr); + return result; + } + + public AsyncResource chatStream(ChatRequest req, StreamingListener listener) { + final AsyncResource result = new AsyncResource(); + String reject = runSafetyFilter(req); + if (reject != null) { + result.error(new LlmInvalidRequestException("Blocked by safety filter: " + reject, + 400, "safety_filter", null)); + return result; + } + final byte[] body; + try { + body = buildChatBody(req, true); + } catch (IOException ioe) { + result.error(ioe); + return result; + } + final StreamingChatRequest scr = new StreamingChatRequest( + resolveUrl("/chat/completions"), body, + listener == null ? new StreamingListener.Adapter() : listener, + result, + new OpenAiSseDecoder(req.getModel() != null ? req.getModel() : defaultModel)) { + // Inherit body / SSE plumbing. + }; + configureRequest(scr, null); + scr.addRequestHeader("Accept", "text/event-stream"); + NetworkManager.getInstance().addToQueue(scr); + // Bridge cancellation: when the caller cancels the AsyncResource + // we kill the underlying socket so no further deltas arrive. + // AsyncResource is Observable and fires `setChanged()` from + // cancel(), complete(), and error(); we only act on cancellation. + result.addObserver(new java.util.Observer() { + public void update(java.util.Observable o, Object arg) { + if (result.isCancelled()) { + scr.kill(); + } + } + }); + return result; + } + + public AsyncResource embed(EmbeddingRequest req) { + final AsyncResource result = new AsyncResource(); + final byte[] body; + try { + Map root = new HashMap(); + root.put("model", req.getModel() != null ? req.getModel() : "text-embedding-3-small"); + if (req.getInputs().size() == 1) { + root.put("input", req.getInputs().get(0)); + } else { + root.put("input", req.getInputs()); + } + if (req.getDimensions() != null) { + root.put("dimensions", req.getDimensions()); + } + body = JsonHelper.serialize(root).getBytes("UTF-8"); + } catch (IOException ioe) { + result.error(ioe); + return result; + } + ConnectionRequest cr = new ConnectionRequest() { + @Override + protected void buildRequestBody(OutputStream os) throws IOException { + os.write(body); + } + + @Override + protected void handleErrorResponseCode(int code, String message) { + } + + @Override + protected void postResponse() { + try { + Map root = JsonHelper.parseObject(getResponseData()); + List dataArr = JsonHelper.asList(root.get("data")); + List out = new ArrayList(dataArr == null ? 0 : dataArr.size()); + if (dataArr != null) { + for (int i = 0; i < dataArr.size(); i++) { + Map e = JsonHelper.asMap(dataArr.get(i)); + List v = JsonHelper.asList(e.get("embedding")); + float[] vec = new float[v == null ? 0 : v.size()]; + for (int j = 0; j < vec.length; j++) { + Object n = v.get(j); + vec[j] = n instanceof Number ? ((Number) n).floatValue() + : Float.parseFloat(n.toString()); + } + out.add(new Embedding(vec, JsonHelper.intValue(e, "index", i))); + } + } + Map usageMap = JsonHelper.asMap(root.get("usage")); + Usage u = usageMap == null ? null + : new Usage( + JsonHelper.intValue(usageMap, "prompt_tokens", -1), + -1, + JsonHelper.intValue(usageMap, "total_tokens", -1)); + final EmbeddingResponse er = new EmbeddingResponse(out, u, + JsonHelper.string(root, "model")); + Display.getInstance().callSerially(new Runnable() { + public void run() { + result.complete(er); + } + }); + } catch (final Exception ex) { + Display.getInstance().callSerially(new Runnable() { + public void run() { + result.error(new LlmException("Failed to parse embedding response", ex)); + } + }); + } + } + + @Override + protected void handleException(Exception errIn) { + final Exception err = errIn; + Display.getInstance().callSerially(new Runnable() { + public void run() { + result.error(new LlmNetworkException(err.getMessage(), err)); + } + }); + } + }; + configureRequest(cr, "/embeddings"); + NetworkManager.getInstance().addToQueue(cr); + return result; + } + + private void configureRequest(ConnectionRequest cr, String pathOrNull) { + if (pathOrNull != null) { + cr.setUrl(resolveUrl(pathOrNull)); + } + cr.setPost(true); + cr.setReadResponseForErrors(true); + cr.setDuplicateSupported(true); + cr.setContentType("application/json"); + cr.setTimeout(getHttpTimeoutMs()); + if (apiKey.length() > 0) { + cr.addRequestHeader("Authorization", "Bearer " + apiKey); + } + } + + private String resolveUrl(String path) { + String base = getBaseUrl(); + if (base == null) { + base = ""; + } + if (base.endsWith("/")) { + base = base.substring(0, base.length() - 1); + } + if (!path.startsWith("/")) { + path = "/" + path; + } + return base + path; + } + + @SuppressWarnings("unchecked") + private byte[] buildChatBody(ChatRequest req, boolean stream) throws IOException { + Map root = new HashMap(); + root.put("model", req.getModel() != null ? req.getModel() : defaultModel); + root.put("messages", encodeMessages(req)); + root.put("stream", stream ? Boolean.TRUE : Boolean.FALSE); + if (stream) { + // Ask for usage in the final SSE chunk; modern OpenAI + // endpoints only emit it when this option is set. + Map so = new HashMap(); + so.put("include_usage", Boolean.TRUE); + root.put("stream_options", so); + } + if (req.getTemperature() != null) root.put("temperature", req.getTemperature()); + if (req.getMaxTokens() != null) root.put("max_tokens", req.getMaxTokens()); + if (req.getTopP() != null) root.put("top_p", req.getTopP()); + if (req.getSeed() != null) root.put("seed", req.getSeed()); + if (!req.getStopSequences().isEmpty()) root.put("stop", req.getStopSequences()); + if (req.getResponseFormat() == ResponseFormat.JSON_OBJECT) { + Map rf = new HashMap(); + rf.put("type", "json_object"); + root.put("response_format", rf); + } + if (!req.getTools().isEmpty()) { + root.put("tools", encodeTools(req.getTools())); + if (req.getToolChoice() != null) { + root.put("tool_choice", encodeToolChoice(req.getToolChoice())); + } + } + if (!req.getMetadata().isEmpty()) { + root.put("metadata", req.getMetadata()); + } + return JsonHelper.serialize(root).getBytes("UTF-8"); + } + + private List encodeMessages(ChatRequest req) { + List out = new ArrayList(req.getMessages().size()); + for (int i = 0; i < req.getMessages().size(); i++) { + ChatMessage m = req.getMessages().get(i); + Map jm = new HashMap(); + jm.put("role", roleString(m.getRole())); + // OpenAI accepts content as either a string (text-only) + // or a content-array (multi-modal). Prefer the string + // form when there's only one TextPart. + if (m.getParts().size() == 1 && m.getParts().get(0) instanceof TextPart) { + jm.put("content", ((TextPart) m.getParts().get(0)).getText()); + } else if (m.getRole() == Role.TOOL && m.getToolCallId() != null) { + jm.put("tool_call_id", m.getToolCallId()); + StringBuilder buf = new StringBuilder(); + for (int p = 0; p < m.getParts().size(); p++) { + MessagePart part = m.getParts().get(p); + if (part instanceof TextPart) { + buf.append(((TextPart) part).getText()); + } else if (part instanceof ToolResultPart) { + buf.append(((ToolResultPart) part).getResultJson()); + } + } + jm.put("content", buf.toString()); + } else { + List parts = new ArrayList(); + for (int p = 0; p < m.getParts().size(); p++) { + MessagePart part = m.getParts().get(p); + if (part instanceof TextPart) { + Map jp = new HashMap(); + jp.put("type", "text"); + jp.put("text", ((TextPart) part).getText()); + parts.add(jp); + } else if (part instanceof ImagePart) { + ImagePart ip = (ImagePart) part; + Map jp = new HashMap(); + jp.put("type", "image_url"); + Map iu = new HashMap(); + if (ip.isUrl()) { + iu.put("url", ip.getUrl()); + } else { + iu.put("url", "data:" + ip.getMimeType() + ";base64," + + com.codename1.util.Base64.encodeNoNewline(ip.getData())); + } + jp.put("image_url", iu); + parts.add(jp); + } + } + jm.put("content", parts); + } + if (m.getName() != null) { + jm.put("name", m.getName()); + } + if (!m.getToolCalls().isEmpty()) { + List tcs = new ArrayList(); + for (ToolCall tc : m.getToolCalls()) { + Map jtc = new HashMap(); + jtc.put("id", tc.getId()); + jtc.put("type", "function"); + Map fn = new HashMap(); + fn.put("name", tc.getName()); + fn.put("arguments", tc.getArgumentsJson()); + jtc.put("function", fn); + tcs.add(jtc); + } + jm.put("tool_calls", tcs); + } + out.add(jm); + } + return out; + } + + private static String roleString(Role r) { + switch (r) { + case SYSTEM: return "system"; + case USER: return "user"; + case ASSISTANT: return "assistant"; + case TOOL: return "tool"; + default: return "user"; + } + } + + private List encodeTools(List tools) { + List out = new ArrayList(tools.size()); + for (Tool t : tools) { + Map jt = new HashMap(); + jt.put("type", "function"); + Map fn = new HashMap(); + fn.put("name", t.getName()); + fn.put("description", t.getDescription()); + fn.put("parameters", new JsonHelper.RawJson(t.getParametersJsonSchema())); + jt.put("function", fn); + out.add(jt); + } + return out; + } + + private Object encodeToolChoice(ToolChoice c) { + if ("named".equals(c.getMode())) { + Map tc = new HashMap(); + tc.put("type", "function"); + Map fn = new HashMap(); + fn.put("name", c.getForcedToolName()); + tc.put("function", fn); + return tc; + } + return c.getMode(); + } +} diff --git a/CodenameOne/src/com/codename1/ai/OpenAiSseDecoder.java b/CodenameOne/src/com/codename1/ai/OpenAiSseDecoder.java new file mode 100644 index 0000000000..b0d1e2b63d --- /dev/null +++ b/CodenameOne/src/com/codename1/ai/OpenAiSseDecoder.java @@ -0,0 +1,244 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ai; + +import com.codename1.ui.Display; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +/// SSE decoder for OpenAI-style `/chat/completions` streams. Also +/// drives Ollama, vLLM, and the other OpenAI-compatible endpoints +/// because the wire format is identical. +/// +/// Accumulates assistant content + tool-call argument fragments and +/// assembles a final [ChatResponse] at stream close. Listener +/// callbacks are dispatched on the EDT via [Display#callSerially]. +final class OpenAiSseDecoder implements StreamingChatRequest.SseDecoder { + + private final String requestedModel; + private final StringBuilder content = new StringBuilder(); + private final List toolCalls = new ArrayList(); + private String finishReason; + private Usage usage; + private String modelUsed; + + OpenAiSseDecoder(String requestedModel) { + this.requestedModel = requestedModel; + } + + public void consume(String dataPayload, final StreamingListener listener) throws Exception { + Map root = JsonHelper.parseObject(dataPayload); + if (root == null) { + return; + } + String modelInChunk = JsonHelper.string(root, "model"); + if (modelInChunk != null) { + modelUsed = modelInChunk; + } + Map u = JsonHelper.asMap(root.get("usage")); + if (u != null) { + usage = new Usage( + JsonHelper.intValue(u, "prompt_tokens", -1), + JsonHelper.intValue(u, "completion_tokens", -1), + JsonHelper.intValue(u, "total_tokens", -1)); + final Usage emit = usage; + Display.getInstance().callSerially(new Runnable() { + public void run() { + listener.onUsage(emit); + } + }); + } + List choices = JsonHelper.asList(root.get("choices")); + if (choices == null || choices.isEmpty()) { + return; + } + Map choice = JsonHelper.asMap(choices.get(0)); + String fr = JsonHelper.string(choice, "finish_reason"); + if (fr != null) { + finishReason = fr; + } + Map delta = JsonHelper.asMap(choice.get("delta")); + if (delta == null) { + // Non-streaming response shape can appear at the very end + // for some servers -- `message` instead of `delta`. + delta = JsonHelper.asMap(choice.get("message")); + if (delta == null) { + return; + } + } + String contentDelta = JsonHelper.string(delta, "content"); + if (contentDelta != null && contentDelta.length() > 0) { + content.append(contentDelta); + final String emit = contentDelta; + Display.getInstance().callSerially(new Runnable() { + public void run() { + listener.onContentDelta(emit); + } + }); + } + List tcs = JsonHelper.asList(delta.get("tool_calls")); + if (tcs != null) { + for (int i = 0; i < tcs.size(); i++) { + Map tc = JsonHelper.asMap(tcs.get(i)); + final int idx = JsonHelper.intValue(tc, "index", i); + while (toolCalls.size() <= idx) { + toolCalls.add(new StreamingToolCall()); + } + StreamingToolCall acc = toolCalls.get(idx); + String id = JsonHelper.string(tc, "id"); + if (id != null) { + acc.id = id; + } + Map fn = JsonHelper.asMap(tc.get("function")); + String name = fn == null ? null : JsonHelper.string(fn, "name"); + if (name != null) { + acc.name = name; + } + String argsFrag = fn == null ? null : JsonHelper.string(fn, "arguments"); + if (argsFrag != null) { + acc.arguments.append(argsFrag); + } + final String emitId = acc.id; + final String emitName = name; + final String emitArgs = argsFrag == null ? "" : argsFrag; + Display.getInstance().callSerially(new Runnable() { + public void run() { + listener.onToolCallDelta(idx, emitId, emitName, emitArgs); + } + }); + } + } + } + + public ChatResponse finish() { + List calls = new ArrayList(toolCalls.size()); + for (int i = 0; i < toolCalls.size(); i++) { + StreamingToolCall sc = toolCalls.get(i); + calls.add(new ToolCall(sc.id, sc.name, sc.arguments.toString())); + } + ChatMessage assistant = new ChatMessage(Role.ASSISTANT, + Arrays.asList(new TextPart(content.toString())), + calls, null, null); + return new ChatResponse(assistant, calls, + finishReason == null ? "stop" : finishReason, + usage, + modelUsed == null ? requestedModel : modelUsed); + } + + public LlmException mapError(int httpStatus, String body) { + return mapErrorStatic(httpStatus, body); + } + + /// Shared with the non-streaming code path, hence static. + static LlmException mapErrorStatic(int httpStatus, String body) { + String code = null; + String message = body; + try { + Map root = JsonHelper.parseObject(body); + Map err = JsonHelper.asMap(root.get("error")); + if (err != null) { + code = JsonHelper.string(err, "code"); + String em = JsonHelper.string(err, "message"); + if (em != null) { + message = em; + } + String type = JsonHelper.string(err, "type"); + if ("context_length_exceeded".equals(code) || "context_length_exceeded".equals(type)) { + return new LlmContextLengthException(message, code, body); + } + } + } catch (Exception ignored) { + } + if (httpStatus == 401 || httpStatus == 403) { + return new LlmAuthException(message, httpStatus, code, body); + } + if (httpStatus == 429) { + return new LlmRateLimitException(message, -1, code, body); + } + if (httpStatus == 503 || httpStatus == 529) { + return new LlmModelOverloadedException(message, httpStatus, code, body); + } + if (httpStatus >= 400 && httpStatus < 500) { + return new LlmInvalidRequestException(message, httpStatus, code, body); + } + if (httpStatus >= 500) { + return new LlmServerException(message, httpStatus, code, body); + } + return new LlmException(message, httpStatus, code, body, null); + } + + /// Parses a single non-streaming response body into a + /// `ChatResponse`. Shared with the synchronous `chat()` path. + static ChatResponse parseNonStreaming(Map root) { + StringBuilder content = new StringBuilder(); + List toolCalls = new ArrayList(); + String finishReason = "stop"; + + List choices = JsonHelper.asList(root.get("choices")); + if (choices != null && !choices.isEmpty()) { + Map choice = JsonHelper.asMap(choices.get(0)); + String fr = JsonHelper.string(choice, "finish_reason"); + if (fr != null) { + finishReason = fr; + } + Map msg = JsonHelper.asMap(choice.get("message")); + if (msg != null) { + String c = JsonHelper.string(msg, "content"); + if (c != null) { + content.append(c); + } + List tcs = JsonHelper.asList(msg.get("tool_calls")); + if (tcs != null) { + for (int i = 0; i < tcs.size(); i++) { + Map tc = JsonHelper.asMap(tcs.get(i)); + Map fn = JsonHelper.asMap(tc.get("function")); + toolCalls.add(new ToolCall( + JsonHelper.string(tc, "id"), + fn == null ? null : JsonHelper.string(fn, "name"), + fn == null ? null : JsonHelper.string(fn, "arguments"))); + } + } + } + } + Map u = JsonHelper.asMap(root.get("usage")); + Usage usage = u == null ? null : new Usage( + JsonHelper.intValue(u, "prompt_tokens", -1), + JsonHelper.intValue(u, "completion_tokens", -1), + JsonHelper.intValue(u, "total_tokens", -1)); + + ChatMessage assistant = new ChatMessage(Role.ASSISTANT, + Arrays.asList(new TextPart(content.toString())), + toolCalls, null, null); + return new ChatResponse(assistant, toolCalls, finishReason, usage, + JsonHelper.string(root, "model")); + } + + private static final class StreamingToolCall { + String id; + String name; + StringBuilder arguments = new StringBuilder(); + } +} diff --git a/CodenameOne/src/com/codename1/ai/PromptTemplate.java b/CodenameOne/src/com/codename1/ai/PromptTemplate.java new file mode 100644 index 0000000000..7bfb090dfa --- /dev/null +++ b/CodenameOne/src/com/codename1/ai/PromptTemplate.java @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ai; + +import java.util.Map; + +/// Trivial `{placeholder}` substitution. Designed for the common +/// "build a prompt from a handful of fields" pattern without pulling +/// in a templating library. For anything more sophisticated (loops, +/// conditionals) just compose strings directly. +/// +/// ``` +/// String prompt = PromptTemplate.of( +/// "You are an expert {role}. Reply in {style}." +/// ).put("role", "tax accountant").put("style", "bullet points").build(); +/// ``` +public final class PromptTemplate { + private final String template; + private final java.util.HashMap values = new java.util.HashMap(); + + private PromptTemplate(String template) { + if (template == null) { + throw new IllegalArgumentException("template is required"); + } + this.template = template; + } + + public static PromptTemplate of(String template) { + return new PromptTemplate(template); + } + + public PromptTemplate put(String key, String value) { + values.put(key, value == null ? "" : value); + return this; + } + + public PromptTemplate putAll(Map map) { + if (map != null) { + values.putAll(map); + } + return this; + } + + /// Renders the final string. Unknown placeholders are left + /// intact (`{like_this}`) so they're easy to spot in test + /// output -- silently dropping them tends to hide bugs. + public String build() { + StringBuilder out = new StringBuilder(template.length() + 32); + int i = 0; + while (i < template.length()) { + char c = template.charAt(i); + if (c == '{') { + int end = template.indexOf('}', i + 1); + if (end > i) { + String key = template.substring(i + 1, end); + String v = values.get(key); + if (v != null) { + out.append(v); + i = end + 1; + continue; + } + } + } + out.append(c); + i++; + } + return out.toString(); + } + + /// Convenience: render and wrap as a [ChatMessage] with USER role. + public ChatMessage asUser() { + return ChatMessage.user(build()); + } + + /// Convenience: render and wrap as a [ChatMessage] with SYSTEM role. + public ChatMessage asSystem() { + return ChatMessage.system(build()); + } +} diff --git a/CodenameOne/src/com/codename1/ai/ResponseFormat.java b/CodenameOne/src/com/codename1/ai/ResponseFormat.java new file mode 100644 index 0000000000..471e89a7e1 --- /dev/null +++ b/CodenameOne/src/com/codename1/ai/ResponseFormat.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ai; + +/// Constrains the model's output format. [#TEXT] is the default; +/// [#JSON_OBJECT] forces the model to emit valid JSON (OpenAI/Gemini +/// honour this directly; Anthropic emulates via a system-prompt +/// guardrail in the client). +public enum ResponseFormat { + TEXT, + JSON_OBJECT +} diff --git a/CodenameOne/src/com/codename1/ai/RetryPolicy.java b/CodenameOne/src/com/codename1/ai/RetryPolicy.java new file mode 100644 index 0000000000..37d17a0a2f --- /dev/null +++ b/CodenameOne/src/com/codename1/ai/RetryPolicy.java @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ai; + +import java.util.Random; + +/// Decides whether and how long to wait before retrying a failed +/// [LlmClient] call. Default policy retries [LlmRateLimitException] +/// (honouring `Retry-After`) and [LlmModelOverloadedException] with +/// exponential backoff + jitter; other failures are surfaced +/// immediately. +/// +/// Wire a policy onto a request like this: +/// +/// ``` +/// AsyncResource r = RetryPolicy.exponentialBackoff() +/// .runChat(client, request); +/// ``` +/// +/// The synchronous block runs on the calling thread. On the EDT it +/// uses `Display.invokeAndBlock` automatically so the UI stays +/// responsive between attempts. +public final class RetryPolicy { + private final int maxAttempts; + private final long initialDelayMs; + private final long maxDelayMs; + private final double multiplier; + private final boolean jitter; + + private static final Random RNG = new Random(); + + private RetryPolicy(int maxAttempts, long initialDelayMs, long maxDelayMs, + double multiplier, boolean jitter) { + this.maxAttempts = Math.max(1, maxAttempts); + this.initialDelayMs = Math.max(1, initialDelayMs); + this.maxDelayMs = Math.max(initialDelayMs, maxDelayMs); + this.multiplier = Math.max(1.0, multiplier); + this.jitter = jitter; + } + + /// 4 attempts, starting at 500 ms, doubling, capped at 30 s, with + /// jitter. Good default for chat workloads. + public static RetryPolicy exponentialBackoff() { + return new RetryPolicy(4, 500L, 30000L, 2.0, true); + } + + /// No retries -- failures are returned to the caller as-is. + public static RetryPolicy none() { + return new RetryPolicy(1, 0L, 0L, 1.0, false); + } + + public static RetryPolicy custom(int maxAttempts, long initialDelayMs, + long maxDelayMs, double multiplier, boolean jitter) { + return new RetryPolicy(maxAttempts, initialDelayMs, maxDelayMs, multiplier, jitter); + } + + /// Inspect a thrown exception and decide whether to retry. Apps + /// can override to add provider-specific rules (e.g. retry on a + /// custom 5xx code). + public boolean shouldRetry(Throwable t, int attemptsSoFar) { + if (attemptsSoFar >= maxAttempts) { + return false; + } + if (t instanceof LlmRateLimitException) { + return true; + } + if (t instanceof LlmModelOverloadedException) { + return true; + } + if (t instanceof LlmServerException) { + // 5xx server errors typically reflect transient state. + return true; + } + if (t instanceof LlmNetworkException) { + return true; + } + return false; + } + + /// Returns the delay to wait before the next attempt, honouring + /// `Retry-After` from rate-limit exceptions when present. + public long computeDelayMs(Throwable t, int attemptIndex /* 0-based */) { + if (t instanceof LlmRateLimitException) { + int retryAfter = ((LlmRateLimitException) t).getRetryAfterSeconds(); + if (retryAfter > 0) { + return retryAfter * 1000L; + } + } + double delay = initialDelayMs; + for (int i = 0; i < attemptIndex; i++) { + delay *= multiplier; + if (delay >= maxDelayMs) { + delay = maxDelayMs; + break; + } + } + if (jitter) { + // Full jitter: pick a random value in [0, delay]. + // Keeps thundering-herd risk down on shared endpoints. + delay = RNG.nextDouble() * delay; + } + return (long) delay; + } + + public int getMaxAttempts() { + return maxAttempts; + } +} diff --git a/CodenameOne/src/com/codename1/ai/Role.java b/CodenameOne/src/com/codename1/ai/Role.java new file mode 100644 index 0000000000..3c7a86197c --- /dev/null +++ b/CodenameOne/src/com/codename1/ai/Role.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ai; + +/// Author of a [ChatMessage]. Maps directly to the role string sent on +/// the wire by every supported LLM provider. +public enum Role { + /// Developer-supplied instructions that steer the assistant. Sent + /// once at the head of the conversation. Gemini receives these as + /// `systemInstruction` rather than a normal message; the Gemini + /// client handles the conversion internally. + SYSTEM, + + /// End-user content (text, images, tool results). + USER, + + /// Model output. When the assistant calls a tool the + /// [ChatMessage] also carries one or more [ToolCall] entries. + ASSISTANT, + + /// Result of a function/tool invocation, sent back to the model so + /// it can continue reasoning. Paired with the originating + /// [ToolCall] via [ChatMessage#getToolCallId()]. + TOOL +} diff --git a/CodenameOne/src/com/codename1/ai/SafetyFilter.java b/CodenameOne/src/com/codename1/ai/SafetyFilter.java new file mode 100644 index 0000000000..1a84d5449b --- /dev/null +++ b/CodenameOne/src/com/codename1/ai/SafetyFilter.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ai; + +import java.util.List; + +/// Pre-flight gate that inspects messages before they're sent to the +/// model. Implementations may call a moderation API, run a local +/// profanity match, or anything else. Returning a non-null reason +/// blocks the chat call and propagates an [LlmInvalidRequestException]. +public interface SafetyFilter { + /// Returns `null` to allow the call, or a human-readable reason + /// string to block it. + String check(List messages); + + /// A built-in filter that allows everything. Useful as a default + /// or as a base for composition. Use `SafetyFilters.openai(key)` + /// (in `com.codename1.ai.filters` or a separate cn1lib) for the + /// OpenAI Moderation gate. + SafetyFilter ALLOW_ALL = new SafetyFilter() { + public String check(List messages) { + return null; + } + }; +} diff --git a/CodenameOne/src/com/codename1/ai/SimulatorRedirect.java b/CodenameOne/src/com/codename1/ai/SimulatorRedirect.java new file mode 100644 index 0000000000..f4cacca206 --- /dev/null +++ b/CodenameOne/src/com/codename1/ai/SimulatorRedirect.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ai; + +import com.codename1.ui.Display; + +/// Centralizes the JavaSE simulator's "redirect every LLM call to a +/// local Ollama" behaviour. Always a no-op on device builds because +/// the relevant system property is only set inside `JavaSEPort`. +/// +/// Three modes via `cn1.ai.simulatorRedirect`: +/// - `disabled` / unset on device: passthrough. +/// - `auto`: on simulator only; if `JavaSEPort` detected Ollama on +/// startup, redirect. Default in the simulator. +/// - `ollama`: force-redirect regardless of detection. +/// +/// The decision is made *per factory call* so an app can flip the +/// property at runtime (the simulator's Tools menu does this). +final class SimulatorRedirect { + + private SimulatorRedirect() { + } + + static LlmClient maybeWrap(LlmClient real) { + if (!isSimulator()) { + return real; + } + // CN1 core's java.lang.System exposes only the single-arg + // getProperty; default-value handling is done by hand. + String mode = readProperty("cn1.ai.simulatorRedirect", "auto"); + boolean force = "ollama".equalsIgnoreCase(mode); + boolean auto = "auto".equalsIgnoreCase(mode); + if (!force && !auto) { + return real; + } + if (auto && !"true".equals(readProperty("cn1.ai.ollamaDetected", "false"))) { + return real; + } + // Build a fresh Ollama-pointed OpenAI-compatible client. We + // intentionally lose the original baseUrl/key here; the user + // opted into local mode and the simulator banner already + // disclosed that. + String localUrl = readProperty("cn1.ai.ollamaUrl", "http://localhost:11434/v1"); + String model = readProperty("cn1.ai.ollamaModel", "llama3.2"); + return LlmClient.localOpenAiCompatible(localUrl, "", model); + } + + private static String readProperty(String key, String defaultValue) { + String v = System.getProperty(key); + return v != null ? v : defaultValue; + } + + private static boolean isSimulator() { + try { + String platform = Display.getInstance().getPlatformName(); + // JavaSEPort returns "se" for the simulator on most + // configurations; check defensively in case that ever + // changes. + return "se".equalsIgnoreCase(platform) + || "javase".equalsIgnoreCase(platform); + } catch (Throwable t) { + return false; + } + } +} diff --git a/CodenameOne/src/com/codename1/ai/StreamingChatRequest.java b/CodenameOne/src/com/codename1/ai/StreamingChatRequest.java new file mode 100644 index 0000000000..17c5ed7676 --- /dev/null +++ b/CodenameOne/src/com/codename1/ai/StreamingChatRequest.java @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ai; + +import com.codename1.io.ConnectionRequest; +import com.codename1.ui.Display; +import com.codename1.util.AsyncResource; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/// Internal `ConnectionRequest` subclass that streams an SSE response +/// line-by-line, dispatching each `data:` payload through a +/// provider-specific parser. Provider-agnostic plumbing (line +/// reassembly, EDT dispatch, error mapping, cancellation, completion +/// signaling) lives here; the parser and the request body are +/// supplied by the concrete provider client. +abstract class StreamingChatRequest extends ConnectionRequest { + private final StreamingListener listener; + private final AsyncResource result; + private final byte[] requestBody; + + /// Provider-supplied accumulator that aggregates `data:` lines + /// into a final [ChatResponse] and fires per-delta listener + /// callbacks along the way. + private final SseDecoder decoder; + + StreamingChatRequest(String url, byte[] requestBody, + StreamingListener listener, + AsyncResource result, + SseDecoder decoder) { + setUrl(url); + setPost(true); + setReadResponseForErrors(true); + setContentType("application/json"); + // The framework's default duplicate-suppression collapses two + // identical-URL chat calls into one; that's actively wrong + // for chats, so opt out. + setDuplicateSupported(true); + this.requestBody = requestBody; + this.listener = listener; + this.result = result; + this.decoder = decoder; + } + + @Override + protected void buildRequestBody(OutputStream os) throws IOException { + if (requestBody != null) { + os.write(requestBody); + } + } + + @Override + protected void readResponse(InputStream input) throws IOException { + int status = getResponseCode(); + if (status >= 400) { + // The framework will normally pump the body into `data` + // and we map to an LlmException via handleErrorResponseCode. + // Still drain so the body is captured. + byte[] body = com.codename1.io.Util.readInputStream(input); + String text = body == null ? "" : new String(body, "UTF-8"); + failWith(decoder.mapError(status, text)); + return; + } + + StringBuilder lineBuf = new StringBuilder(256); + StringBuilder dataBuf = new StringBuilder(1024); + int b; + while (!isKilled() && (b = input.read()) != -1) { + if (b == '\n') { + String line = lineBuf.toString(); + lineBuf.setLength(0); + if (line.length() > 0 && line.charAt(line.length() - 1) == '\r') { + line = line.substring(0, line.length() - 1); + } + if (line.length() == 0) { + // Dispatch the accumulated event. + if (dataBuf.length() > 0) { + dispatchEvent(dataBuf.toString()); + dataBuf.setLength(0); + } + } else if (line.startsWith("data:")) { + String payload = line.substring(5); + if (payload.length() > 0 && payload.charAt(0) == ' ') { + payload = payload.substring(1); + } + if (dataBuf.length() > 0) { + dataBuf.append('\n'); + } + dataBuf.append(payload); + } + // Ignore other line types (event:, id:, retry:, comments). + } else { + lineBuf.append((char) b); + } + } + // Final dispatch for any trailing event without a blank-line + // terminator (some providers omit the trailing CRLF). + if (dataBuf.length() > 0) { + dispatchEvent(dataBuf.toString()); + } + if (isKilled()) { + return; + } + // Stream ended without an explicit terminator from the + // decoder; ask it to finalize whatever it has. + ChatResponse finalResponse = decoder.finish(); + completeWith(finalResponse); + } + + private void dispatchEvent(String payload) { + if ("[DONE]".equals(payload)) { + // OpenAI / Ollama sentinel -- the next call to finish() + // will assemble whatever the decoder accumulated. + return; + } + try { + decoder.consume(payload, listener); + } catch (Throwable t) { + failWith(t); + } + } + + private void completeWith(final ChatResponse r) { + Display.getInstance().callSerially(new Runnable() { + public void run() { + if (!result.isDone()) { + result.complete(r); + } + } + }); + } + + private void failWith(final Throwable t) { + Display.getInstance().callSerially(new Runnable() { + public void run() { + if (listener != null) { + try { + listener.onError(t); + } catch (Throwable ignore) { + } + } + if (!result.isDone()) { + result.error(t); + } + } + }); + } + + /// Invoked by the framework when an exception escapes the network + /// path. Convert to an `LlmNetworkException` and surface. + @Override + protected void handleException(Exception err) { + failWith(new LlmNetworkException(err.getMessage(), err)); + } + + /// Provider-specific SSE event decoder. Implementations are + /// stateful (accumulating text content and tool-call fragments) + /// and call back into the listener as deltas arrive. + interface SseDecoder { + /// Process one `data:` payload. Fire listener deltas as needed. + void consume(String dataPayload, StreamingListener listener) throws Exception; + + /// Build the final aggregated [ChatResponse]. Called when the + /// stream closes cleanly (either after `[DONE]` or after EOF). + ChatResponse finish(); + + /// Convert an HTTP-error body into the right [LlmException] + /// subtype. + LlmException mapError(int httpStatus, String body); + } +} diff --git a/CodenameOne/src/com/codename1/ai/StreamingListener.java b/CodenameOne/src/com/codename1/ai/StreamingListener.java new file mode 100644 index 0000000000..cfc5c2c94a --- /dev/null +++ b/CodenameOne/src/com/codename1/ai/StreamingListener.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ai; + +/// Callback for [LlmClient#chatStream]. Every method is invoked on the +/// EDT; implementations can update UI directly without further +/// marshalling. The terminal [ChatResponse] is delivered via the +/// `AsyncResource` returned by `chatStream`, not via this listener. +/// +/// Tool-call streaming differs by provider: OpenAI and Anthropic emit +/// argument JSON in fragments (potentially many `onToolCallDelta` +/// calls per tool); Gemini delivers tool calls atomically at the end +/// (a single call with the complete `argumentsFragment` set to the +/// full JSON). Either pattern is valid. +public interface StreamingListener { + /// A chunk of assistant text. Append it to whatever text buffer + /// you're rendering. + void onContentDelta(String textDelta); + + /// A tool-call fragment. `index` lets you correlate fragments + /// that belong to the same call when multiple tools are streamed + /// in parallel. `name` is non-null on the first fragment for each + /// call. `argumentsFragment` is the next slice of the arguments + /// JSON; concatenate fragments for the same `index` to reassemble. + /// `id` is the provider's tool-call id, present on the first + /// fragment. + void onToolCallDelta(int index, String id, String name, String argumentsFragment); + + /// Token-accounting update. Most providers send this once at the + /// end; some send incremental counts. + void onUsage(Usage usage); + + /// Mid-stream error (e.g. connection reset). The `AsyncResource` + /// returned by `chatStream` will also complete with this same + /// exception, so listeners can typically ignore this and react to + /// the resource. Implemented for parity with other SDKs. + void onError(Throwable t); + + /// No-op default implementation. Subclass and override only what + /// you need. + public static class Adapter implements StreamingListener { + public void onContentDelta(String textDelta) { + } + + public void onToolCallDelta(int index, String id, String name, String argumentsFragment) { + } + + public void onUsage(Usage usage) { + } + + public void onError(Throwable t) { + } + } +} diff --git a/CodenameOne/src/com/codename1/ai/TextPart.java b/CodenameOne/src/com/codename1/ai/TextPart.java new file mode 100644 index 0000000000..45ee958d1f --- /dev/null +++ b/CodenameOne/src/com/codename1/ai/TextPart.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ai; + +/// A plain-text fragment of a [ChatMessage]. +public final class TextPart extends MessagePart { + private final String text; + + public TextPart(String text) { + this.text = text == null ? "" : text; + } + + public String getText() { + return text; + } +} diff --git a/CodenameOne/src/com/codename1/ai/Tokenizer.java b/CodenameOne/src/com/codename1/ai/Tokenizer.java new file mode 100644 index 0000000000..e322c9cb51 --- /dev/null +++ b/CodenameOne/src/com/codename1/ai/Tokenizer.java @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ai; + +import java.util.List; + +/// Rough best-effort token counting. Useful for the common case of +/// "am I likely to exceed this model's context window?" without +/// shipping the full BPE table (cl100k_base is ~1.7 MB which is +/// substantial for a mobile binary). +/// +/// The rule of thumb is **1 token ~= 4 characters** of English text, +/// which holds within ~10-15% for typical chat traffic. For non-Latin +/// scripts the ratio is closer to 1:1, so we clamp the lower bound at +/// the rough number of words. Apps that need exact accounting should +/// fetch a usage value from the API response and adjust their budget. +public final class Tokenizer { + + private Tokenizer() { + } + + /// Approximate token count for `text`. + public static int estimate(String text) { + if (text == null || text.length() == 0) { + return 0; + } + int characters = text.length(); + int byChars = Math.max(1, characters / 4); + int words = 0; + boolean inWord = false; + for (int i = 0; i < characters; i++) { + char c = text.charAt(i); + if (Character.isWhitespace(c)) { + inWord = false; + } else if (!inWord) { + inWord = true; + words++; + } + } + return Math.max(byChars, words); + } + + /// Estimate the prompt-tokens cost of an entire conversation. + /// Adds a small fixed overhead per message to approximate the + /// role / formatting tokens the provider includes. + public static int estimateMessages(List messages) { + if (messages == null || messages.isEmpty()) { + return 0; + } + int total = 0; + for (ChatMessage m : messages) { + total += 4; // role + framing overhead + total += estimate(m.getText()); + } + return total + 2; // priming + } +} diff --git a/CodenameOne/src/com/codename1/ai/Tool.java b/CodenameOne/src/com/codename1/ai/Tool.java new file mode 100644 index 0000000000..4d38e60459 --- /dev/null +++ b/CodenameOne/src/com/codename1/ai/Tool.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ai; + +/// A function the model can call. `parametersJsonSchema` is a raw +/// JSON-Schema string; each provider wraps it differently on the wire +/// (OpenAI `{type:"function",function:{...}}`, Anthropic +/// `{name,description,input_schema}`, Gemini `functionDeclarations`), +/// but the inner schema shape is the same across all of them, so we +/// hand it through as a string and let the provider client wrap. +/// +/// #### Example +/// +/// ``` +/// Tool t = new Tool( +/// "get_weather", +/// "Returns the current weather for a location", +/// "{\"type\":\"object\",\"properties\":{" + +/// "\"location\":{\"type\":\"string\"}}," + +/// "\"required\":[\"location\"]}"); +/// ``` +public final class Tool { + private final String name; + private final String description; + private final String parametersJsonSchema; + + public Tool(String name, String description, String parametersJsonSchema) { + if (name == null || name.length() == 0) { + throw new IllegalArgumentException("name is required"); + } + this.name = name; + this.description = description == null ? "" : description; + this.parametersJsonSchema = parametersJsonSchema == null + ? "{\"type\":\"object\",\"properties\":{}}" + : parametersJsonSchema; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public String getParametersJsonSchema() { + return parametersJsonSchema; + } +} diff --git a/CodenameOne/src/com/codename1/ai/ToolCall.java b/CodenameOne/src/com/codename1/ai/ToolCall.java new file mode 100644 index 0000000000..eb2167dcb7 --- /dev/null +++ b/CodenameOne/src/com/codename1/ai/ToolCall.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ai; + +/// A tool/function invocation produced by the model. The `id` round- +/// trips the call back to a [ToolResultPart] so the provider can +/// match the result to the original request. `argumentsJson` is the +/// raw JSON string the model produced -- parse it with +/// `com.codename1.io.JSONParser` if you need the structured fields. +public final class ToolCall { + private final String id; + private final String name; + private final String argumentsJson; + + public ToolCall(String id, String name, String argumentsJson) { + this.id = id; + this.name = name; + this.argumentsJson = argumentsJson == null ? "{}" : argumentsJson; + } + + public String getId() { + return id; + } + + public String getName() { + return name; + } + + public String getArgumentsJson() { + return argumentsJson; + } +} diff --git a/CodenameOne/src/com/codename1/ai/ToolChoice.java b/CodenameOne/src/com/codename1/ai/ToolChoice.java new file mode 100644 index 0000000000..018e17c454 --- /dev/null +++ b/CodenameOne/src/com/codename1/ai/ToolChoice.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ai; + +/// Controls how aggressively the model will call tools. Use the +/// constants for the three common modes; use [#named(String)] to force +/// the model to call a specific tool. +public final class ToolChoice { + /// Model picks freely between calling tools and replying with text. + public static final ToolChoice AUTO = new ToolChoice("auto", null); + + /// Model must not call any tool -- it must reply with text. + public static final ToolChoice NONE = new ToolChoice("none", null); + + /// Model must call exactly one tool (any tool). Useful for forcing + /// a structured-output path. + public static final ToolChoice REQUIRED = new ToolChoice("required", null); + + private final String mode; + private final String forcedToolName; + + private ToolChoice(String mode, String forcedToolName) { + this.mode = mode; + this.forcedToolName = forcedToolName; + } + + /// Forces the model to call the named tool. + public static ToolChoice named(String toolName) { + if (toolName == null || toolName.length() == 0) { + throw new IllegalArgumentException("toolName is required"); + } + return new ToolChoice("named", toolName); + } + + public String getMode() { + return mode; + } + + public String getForcedToolName() { + return forcedToolName; + } +} diff --git a/CodenameOne/src/com/codename1/ai/ToolResultPart.java b/CodenameOne/src/com/codename1/ai/ToolResultPart.java new file mode 100644 index 0000000000..0ea7ea02a7 --- /dev/null +++ b/CodenameOne/src/com/codename1/ai/ToolResultPart.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ai; + +/// The result of a tool invocation, sent back to the model so it can +/// continue reasoning. Pairs with the originating [ToolCall] via the +/// `toolCallId`. The carrying [ChatMessage] should use [Role#TOOL]. +public final class ToolResultPart extends MessagePart { + private final String toolCallId; + private final String resultJson; + + /// `resultJson` is the literal JSON string the tool produced. If + /// the tool result isn't valid JSON, wrap it like + /// `"{\"text\":\"...\"}"` -- the providers expect JSON-shaped values. + public ToolResultPart(String toolCallId, String resultJson) { + this.toolCallId = toolCallId; + this.resultJson = resultJson == null ? "" : resultJson; + } + + public String getToolCallId() { + return toolCallId; + } + + public String getResultJson() { + return resultJson; + } +} diff --git a/CodenameOne/src/com/codename1/ai/Usage.java b/CodenameOne/src/com/codename1/ai/Usage.java new file mode 100644 index 0000000000..e7cf089ea1 --- /dev/null +++ b/CodenameOne/src/com/codename1/ai/Usage.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.ai; + +/// Token accounting returned by the provider. Any field that the +/// provider didn't return is `-1`. +public final class Usage { + private final int promptTokens; + private final int completionTokens; + private final int totalTokens; + + public Usage(int promptTokens, int completionTokens, int totalTokens) { + this.promptTokens = promptTokens; + this.completionTokens = completionTokens; + this.totalTokens = totalTokens; + } + + public int getPromptTokens() { + return promptTokens; + } + + public int getCompletionTokens() { + return completionTokens; + } + + public int getTotalTokens() { + return totalTokens; + } +} diff --git a/CodenameOne/src/com/codename1/components/ChatBubble.java b/CodenameOne/src/com/codename1/components/ChatBubble.java new file mode 100644 index 0000000000..b6d391c961 --- /dev/null +++ b/CodenameOne/src/com/codename1/components/ChatBubble.java @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.components; + +import com.codename1.ai.ChatMessage; +import com.codename1.ai.Role; +import com.codename1.ui.Container; +import com.codename1.ui.Display; +import com.codename1.ui.TextArea; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.plaf.UIManager; + +/// One row in a [ChatView]. Renders a [ChatMessage] as a styled +/// container holding a `TextArea` for the body text. Defaults the +/// UIID based on the message [Role]: `ChatBubbleUser`, +/// `ChatBubbleAssistant`, `ChatBubbleSystem`. +/// +/// The body `TextArea` is non-editable and uses native scrolling +/// behaviour off; it wraps within the bubble. Apps that want richer +/// rendering (markdown, code blocks) can subclass and override +/// [#renderBody] without rewriting the wrapper. +public class ChatBubble extends Container { + private final TextArea body; + private final ChatMessage message; + + public ChatBubble(ChatMessage message) { + super(new BorderLayout()); + this.message = message; + setUIID(defaultUiidFor(message.getRole())); + this.body = new TextArea(message.getText()); + body.setEditable(false); + body.setUIID("ChatBubbleText"); + body.setGrowByContent(true); + body.setActAsLabel(true); + body.getAllStyles().setBgTransparency(0); + add(BorderLayout.CENTER, body); + } + + /// Replace the bubble's body text and re-render. Safe to call + /// from any thread; the actual mutation is marshalled to the + /// EDT. + public void setText(final String text) { + if (Display.getInstance().isEdt()) { + applyText(text); + return; + } + Display.getInstance().callSerially(new Runnable() { + public void run() { + applyText(text); + } + }); + } + + private void applyText(String text) { + body.setText(text == null ? "" : text); + revalidateLater(); + } + + /// Append a token-sized delta to the bubble's body. Used by + /// [ChatView#appendToLastMessage] during LLM streaming. + public void appendText(final String delta) { + if (delta == null || delta.length() == 0) { + return; + } + if (Display.getInstance().isEdt()) { + applyText(body.getText() + delta); + return; + } + Display.getInstance().callSerially(new Runnable() { + public void run() { + applyText(body.getText() + delta); + } + }); + } + + public ChatMessage getMessage() { + return message; + } + + public String getBubbleText() { + return body.getText(); + } + + /// Returns the inner `TextArea` for styling tweaks beyond the + /// UIID hooks (e.g. setting a custom font). + protected TextArea getBody() { + return body; + } + + private static String defaultUiidFor(Role role) { + if (role == Role.USER) return "ChatBubbleUser"; + if (role == Role.ASSISTANT) return "ChatBubbleAssistant"; + if (role == Role.SYSTEM) return "ChatBubbleSystem"; + return "ChatBubble"; + } + + /// Subclass hook for custom rendering of the body. Default + /// behaviour is to keep the inner TextArea in sync with whatever + /// text has been set; override to swap in a different child + /// component. + protected void renderBody() { + // Default: nothing to do -- the wrapper already adds the + // TextArea in the constructor. + } + + @Override + protected void initComponent() { + super.initComponent(); + // Honor any theme-driven UIID overrides at component-attach + // time. UIManager will pick up the user's CSS / .res theme. + UIManager um = UIManager.getInstance(); + if (um != null) { + // The theme is consulted by the framework when styles + // are read; no explicit work needed beyond ensuring the + // UIID is set before this point. + } + } +} diff --git a/CodenameOne/src/com/codename1/components/ChatInput.java b/CodenameOne/src/com/codename1/components/ChatInput.java new file mode 100644 index 0000000000..5b4b66800e --- /dev/null +++ b/CodenameOne/src/com/codename1/components/ChatInput.java @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.components; + +import com.codename1.ui.Button; +import com.codename1.ui.Container; +import com.codename1.ui.TextField; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; + +/// The input strip at the bottom of a [ChatView]. Contains an +/// optional attach button, a single-line text field, an optional +/// voice button, and a send button. +/// +/// Each button is exposed via a setter ([#setOnAttach], +/// [#setOnVoice], [#setOnSend]) and visible only when its listener +/// is non-null. Listeners receive an [ActionEvent] whose `source` +/// is this [ChatInput]. +/// +/// Default UIIDs: `ChatInput` on the container, `ChatInputField` on +/// the text field, `ChatSendButton` / `ChatAttachButton` / +/// `ChatVoiceButton` on the respective buttons. +public class ChatInput extends Container { + private final TextField field; + private final Button send; + private final Button attach; + private final Button voice; + + private ActionListener onSend; + private ActionListener onAttach; + private ActionListener onVoice; + + public ChatInput() { + super(new BorderLayout()); + setUIID("ChatInput"); + field = new TextField(); + field.setUIID("ChatInputField"); + field.setSingleLineTextArea(false); + field.setHint("Message"); + send = new Button("Send"); + send.setUIID("ChatSendButton"); + send.setVisible(false); + attach = new Button("+"); + attach.setUIID("ChatAttachButton"); + attach.setVisible(false); + voice = new Button("Mic"); + voice.setUIID("ChatVoiceButton"); + voice.setVisible(false); + + // Pressing Enter (or "Done" on a mobile soft keyboard) acts + // like tapping Send. + field.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent evt) { + fireSendIfNonEmpty(); + } + }); + send.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent evt) { + fireSendIfNonEmpty(); + } + }); + attach.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent evt) { + if (onAttach != null) { + onAttach.actionPerformed(new ActionEvent(ChatInput.this)); + } + } + }); + voice.addActionListener(new ActionListener() { + public void actionPerformed(ActionEvent evt) { + if (onVoice != null) { + onVoice.actionPerformed(new ActionEvent(ChatInput.this)); + } + } + }); + + Container right = new Container(new BoxLayout(BoxLayout.X_AXIS)); + right.add(voice); + right.add(send); + add(BorderLayout.WEST, attach); + add(BorderLayout.CENTER, field); + add(BorderLayout.EAST, right); + } + + public TextField getField() { + return field; + } + + public String getText() { + return field.getText() == null ? "" : field.getText(); + } + + public void setText(String text) { + field.setText(text); + } + + public void clear() { + field.setText(""); + } + + public ChatInput setOnSend(ActionListener listener) { + this.onSend = listener; + send.setVisible(listener != null); + return this; + } + + public ChatInput setOnAttach(ActionListener listener) { + this.onAttach = listener; + attach.setVisible(listener != null); + return this; + } + + public ChatInput setOnVoice(ActionListener listener) { + this.onVoice = listener; + voice.setVisible(listener != null); + return this; + } + + public Button getSendButton() { + return send; + } + + public Button getAttachButton() { + return attach; + } + + public Button getVoiceButton() { + return voice; + } + + private void fireSendIfNonEmpty() { + String t = getText(); + if (t.length() == 0 || onSend == null) { + return; + } + onSend.actionPerformed(new ActionEvent(this)); + } +} diff --git a/CodenameOne/src/com/codename1/components/ChatView.java b/CodenameOne/src/com/codename1/components/ChatView.java new file mode 100644 index 0000000000..06a0a1691e --- /dev/null +++ b/CodenameOne/src/com/codename1/components/ChatView.java @@ -0,0 +1,243 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.components; + +import com.codename1.ai.ChatMessage; +import com.codename1.ai.ChatRequest; +import com.codename1.ai.ChatResponse; +import com.codename1.ai.LlmClient; +import com.codename1.ai.Role; +import com.codename1.ai.StreamingListener; +import com.codename1.ai.Usage; +import com.codename1.ui.Container; +import com.codename1.ui.Display; +import com.codename1.ui.Label; +import com.codename1.ui.events.ActionEvent; +import com.codename1.ui.events.ActionListener; +import com.codename1.ui.layouts.BorderLayout; +import com.codename1.ui.layouts.BoxLayout; +import com.codename1.util.AsyncResource; +import com.codename1.util.SuccessCallback; + +import java.util.ArrayList; +import java.util.List; + +/// A scrollable, theme-aware chat surface. +/// +/// `ChatView` holds a vertical stack of [ChatBubble]s rendered from +/// [ChatMessage]s plus a [ChatInput] strip at the bottom. The stack +/// auto-scrolls to the latest message when one is added. +/// +/// #### Streaming +/// +/// During an LLM streaming call, the typical pattern is: +/// +/// ``` +/// chatView.addMessage(ChatMessage.user(userText)); +/// ChatBubble assistant = chatView.beginAssistantStream(); +/// client.chatStream(req, new StreamingListener.Adapter() { +/// public void onContentDelta(String d) { assistant.appendText(d); } +/// }); +/// ``` +/// +/// Use [#bindToLlm(LlmClient, ChatRequest)] to wire the entire +/// flow in one call when the surrounding logic is straightforward. +/// +/// #### Default UIIDs +/// +/// `ChatView`, `ChatViewMessages`, `ChatBubbleUser`, +/// `ChatBubbleAssistant`, `ChatBubbleSystem`, `ChatBubbleText`, +/// `ChatTypingIndicator`, `ChatInput`, `ChatInputField`, +/// `ChatSendButton`, `ChatAttachButton`, `ChatVoiceButton`. +public class ChatView extends Container { + private final Container messages; + private final ChatInput input; + private final Label typing; + private final List history = new ArrayList(); + private final List bubbles = new ArrayList(); + + public ChatView() { + super(new BorderLayout()); + setUIID("ChatView"); + messages = new Container(new BoxLayout(BoxLayout.Y_AXIS)); + messages.setUIID("ChatViewMessages"); + messages.setScrollableY(true); + typing = new Label("..."); + typing.setUIID("ChatTypingIndicator"); + typing.setVisible(false); + input = new ChatInput(); + + Container bottom = new Container(new BoxLayout(BoxLayout.Y_AXIS)); + bottom.add(typing); + bottom.add(input); + + add(BorderLayout.CENTER, messages); + add(BorderLayout.SOUTH, bottom); + } + + /// Renders `message` as a new [ChatBubble] at the bottom of the + /// list and scrolls into view. Safe to call from any thread. + public ChatBubble addMessage(final ChatMessage message) { + final ChatBubble[] out = new ChatBubble[1]; + Runnable r = new Runnable() { + public void run() { + ChatBubble b = createBubble(message); + history.add(message); + bubbles.add(b); + messages.add(b); + messages.revalidateLater(); + messages.scrollComponentToVisible(b); + out[0] = b; + } + }; + if (Display.getInstance().isEdt()) { + r.run(); + } else { + // Wait for the EDT to insert so we can return the bubble + // synchronously to the caller. The caller is on a worker + // thread by definition (we just checked); blocking here + // is fine. + Display.getInstance().callSeriallyAndWait(r); + } + return out[0]; + } + + /// Convenience that appends an empty assistant bubble for an + /// upcoming streaming response. Returns the bubble so the + /// caller's `StreamingListener` can [ChatBubble#appendText] into + /// it. + public ChatBubble beginAssistantStream() { + return addMessage(new ChatMessage(Role.ASSISTANT, + java.util.Arrays.asList( + new com.codename1.ai.TextPart("")))); + } + + /// Append a streaming token delta to the most recently added + /// bubble. Safe to call off-EDT. No-op when there is no + /// bubble yet. + public void appendToLastMessage(String delta) { + if (bubbles.isEmpty()) { + return; + } + bubbles.get(bubbles.size() - 1).appendText(delta); + } + + public void setTypingIndicatorVisible(final boolean v) { + Runnable r = new Runnable() { + public void run() { + typing.setVisible(v); + typing.getParent().revalidateLater(); + } + }; + if (Display.getInstance().isEdt()) { + r.run(); + } else { + Display.getInstance().callSerially(r); + } + } + + public void setOnSend(ActionListener listener) { + input.setOnSend(listener); + } + + public void setOnAttach(ActionListener listener) { + input.setOnAttach(listener); + } + + public void setOnVoice(ActionListener listener) { + input.setOnVoice(listener); + } + + public ChatInput getInput() { + return input; + } + + public List getHistory() { + return java.util.Collections.unmodifiableList(history); + } + + /// Wires this view to an [LlmClient] so the user can type in the + /// input bar and see streamed responses appear automatically. + /// `baseRequest` carries model, temperature, tools, etc.; the + /// view's accumulated history is substituted for `messages` on + /// every turn so [#addMessage(ChatMessage)] calls (and any + /// initial system message you've already supplied) participate. + public void bindToLlm(final LlmClient client, final ChatRequest baseRequest) { + setOnSend(new ActionListener() { + public void actionPerformed(ActionEvent evt) { + final String text = input.getText(); + if (text == null || text.length() == 0) { + return; + } + input.clear(); + addMessage(ChatMessage.user(text)); + setTypingIndicatorVisible(true); + final ChatBubble assistant = beginAssistantStream(); + ChatRequest replay = baseRequest.toBuilder() + .messages(buildOutgoingMessages(baseRequest)) + .build(); + AsyncResource result = client.chatStream(replay, + new StreamingListener() { + public void onContentDelta(String textDelta) { + assistant.appendText(textDelta); + } + + public void onToolCallDelta(int index, String id, String name, String argumentsFragment) { + // Default binding doesn't surface + // tool calls -- apps that use tools + // should wire up their own handler. + } + + public void onUsage(Usage usage) { + } + + public void onError(Throwable t) { + assistant.appendText("\n\n[error: " + t.getMessage() + "]"); + } + }); + result.ready(new SuccessCallback() { + public void onSucess(ChatResponse arg) { + setTypingIndicatorVisible(false); + } + }); + } + }); + } + + private List buildOutgoingMessages(ChatRequest baseRequest) { + // Prefer the live history (which now includes the just-added + // user message). Fall back to whatever was in the base + // request if the view has nothing yet -- useful when the + // app pre-loads a system prompt via baseRequest. + if (history.isEmpty()) { + return baseRequest.getMessages(); + } + return new ArrayList(history); + } + + /// Override to swap in a custom bubble renderer (e.g. one that + /// understands markdown). Default delegates to [ChatBubble]. + protected ChatBubble createBubble(ChatMessage message) { + return new ChatBubble(message); + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AiDependencyTable.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AiDependencyTable.java new file mode 100644 index 0000000000..2f57b9ca8c --- /dev/null +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AiDependencyTable.java @@ -0,0 +1,364 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * Central registry that maps class-name prefixes used by the + * {@code com.codename1.ai.*} family (and the speech/TTS sister APIs + * in {@code com.codename1.media}) to the native dependencies and + * permissions each one requires. + * + *

The build server's class scanners ({@link IPhoneBuilder} and + * {@link AndroidGradleBuilder}) call into this table from inside their + * existing {@link Executor.ClassScanner#usesClass(String)} blocks; the + * resulting set of {@link Entry} records is then applied just before + * iOS pods / SPM are resolved and just before the Android Gradle + * dependencies / manifest fragments are written.

+ * + *

Keep this table small and declarative: any class prefix whose + * needs change (different pod version, additional plist entry) should + * be edited here, not in the builder hot loop.

+ */ +public final class AiDependencyTable { + + private static final List ENTRIES; + + static { + List e = new ArrayList(); + + // LLM clients: pure HTTPS. INTERNET is on by default on + // Android so no permission needed; we still register the + // entry so the scanner has a positive hit for diagnostics. + e.add(new Entry("com/codename1/ai/LlmClient") + .description("LLM client (OpenAI / Anthropic / Gemini / Ollama)")); + e.add(new Entry("com/codename1/ai/OpenAiClient").description("OpenAI client")); + e.add(new Entry("com/codename1/ai/AnthropicClient").description("Anthropic client")); + e.add(new Entry("com/codename1/ai/GeminiClient").description("Gemini client")); + + // Core speech recognition: iOS Speech framework + mic & speech plist + // strings; Android record-audio permission. The TTS API has no + // plist requirement (AVSpeech is unrestricted) and no Android + // permission (built-in). + e.add(new Entry("com/codename1/media/SpeechRecognizer") + .iosFrameworks("Speech", "AVFoundation") + .iosPlist("NSSpeechRecognitionUsageDescription", + "Used to transcribe your voice into text.") + .iosPlist("NSMicrophoneUsageDescription", + "Required to capture audio for speech recognition.") + .androidPermissions("android.permission.RECORD_AUDIO") + .description("On-device speech-to-text")); + + e.add(new Entry("com/codename1/media/TextToSpeech") + .iosFrameworks("AVFAudio") + .description("Text-to-speech")); + + // ML Kit feature submodules. Class prefix matches the + // (forward-referenced) cn1libs' package layout. + e.add(new Entry("com/codename1/ai/mlkit/text/") + .iosPod("GoogleMLKit/TextRecognition") + .androidGradle("com.google.mlkit:text-recognition:16.0.0") + .iosPlist("NSCameraUsageDescription", + "Used to recognise text from your camera.") + .description("ML Kit Text Recognition")); + + e.add(new Entry("com/codename1/ai/mlkit/barcode/") + .iosPod("GoogleMLKit/BarcodeScanning") + .androidGradle("com.google.mlkit:barcode-scanning:17.2.0") + .iosPlist("NSCameraUsageDescription", + "Used to scan barcodes with your camera.") + .androidFeatures("android.hardware.camera") + .description("ML Kit Barcode Scanning")); + + e.add(new Entry("com/codename1/ai/mlkit/face/") + .iosPod("GoogleMLKit/FaceDetection") + .androidGradle("com.google.mlkit:face-detection:16.1.5") + .iosPlist("NSCameraUsageDescription", + "Used to detect faces in images.") + .description("ML Kit Face Detection")); + + e.add(new Entry("com/codename1/ai/mlkit/labeling/") + .iosPod("GoogleMLKit/ImageLabeling") + .androidGradle("com.google.mlkit:image-labeling:17.0.7") + .description("ML Kit Image Labeling")); + + e.add(new Entry("com/codename1/ai/mlkit/translate/") + .iosPod("GoogleMLKit/Translate") + .androidGradle("com.google.mlkit:translate:17.0.1") + .description("ML Kit Translation")); + + e.add(new Entry("com/codename1/ai/mlkit/smartreply/") + .iosPod("GoogleMLKit/SmartReply") + .androidGradle("com.google.mlkit:smart-reply:17.0.2") + .description("ML Kit Smart Reply")); + + e.add(new Entry("com/codename1/ai/mlkit/langid/") + .iosPod("GoogleMLKit/LanguageID") + .androidGradle("com.google.mlkit:language-id:17.0.4") + .description("ML Kit Language ID")); + + e.add(new Entry("com/codename1/ai/mlkit/pose/") + .iosPod("GoogleMLKit/PoseDetection") + .androidGradle("com.google.mlkit:pose-detection:18.0.0-beta3") + .description("ML Kit Pose Detection")); + + e.add(new Entry("com/codename1/ai/mlkit/segmentation/") + .iosPod("GoogleMLKit/SegmentationSelfie") + .androidGradle("com.google.mlkit:segmentation-selfie:16.0.0-beta4") + .description("ML Kit Selfie Segmentation")); + + e.add(new Entry("com/codename1/ai/mlkit/docscan/") + .iosPod("GoogleMLKit/DocumentScanner") + .iosFrameworks("VisionKit") + .androidGradle("com.google.android.gms:play-services-mlkit-document-scanner:16.0.0-beta1") + .description("ML Kit Document Scanner")); + + // TFLite has both Pods and SPM publishers. We register both + // so IOSDependencyManager.resolve() can route to whichever + // the project uses. + e.add(new Entry("com/codename1/ai/tflite/") + .iosPod("TensorFlowLiteSwift") + .iosSpm("TensorFlowLiteSwift", + "https://github.com/tensorflow/tensorflow.git", + "from:2.13.0", + "TensorFlowLite") + .androidGradle("org.tensorflow:tensorflow-lite:2.13.0") + .androidGradle("org.tensorflow:tensorflow-lite-support:0.4.4") + .description("TensorFlow Lite interpreter")); + + e.add(new Entry("com/codename1/ai/whisper/") + .iosFrameworks("Accelerate") + .description("On-device Whisper transcription (libwhisper.a ships with the cn1lib)")); + + // On-device Stable Diffusion: bundled Core ML model on iOS, + // ONNX runtime on Android. Flag the >2 GB upload concern so + // the cloud build server can abort early with a helpful + // message. + e.add(new Entry("com/codename1/ai/imagegen/StableDiffusion") + .iosFrameworks("CoreML", "Vision") + .androidGradle("com.microsoft.onnxruntime:onnxruntime-android:1.16.3") + .markBigUpload() + .description("On-device Stable Diffusion (local-build only)")); + + ENTRIES = Collections.unmodifiableList(e); + } + + private AiDependencyTable() { + } + + /** All registered entries. Mostly useful for tests and tooling. */ + public static List entries() { + return ENTRIES; + } + + /** + * Returns every entry whose {@link Entry#classPrefix} matches the + * given internal-form class name (slashes, not dots). When the + * prefix ends with a slash, package-prefix matching is used; + * otherwise an exact class match is required. + */ + public static List matchesFor(String internalClassName) { + if (internalClassName == null) { + return Collections.emptyList(); + } + List out = new ArrayList(); + for (Entry e : ENTRIES) { + if (e.matches(internalClassName)) { + out.add(e); + } + } + return out; + } + + /** + * Builder/scanner output: the de-duplicated union of every entry + * fired by a class scan. Use {@link Accumulator#consume(String)} + * from inside an {@link Executor.ClassScanner#usesClass(String)} + * implementation. + */ + public static final class Accumulator { + private final Set hits = new LinkedHashSet(); + + public void consume(String internalClassName) { + hits.addAll(matchesFor(internalClassName)); + } + + public Set hits() { + return hits; + } + + public boolean anyRequiresBigUpload() { + for (Entry e : hits) { + if (e.requiresBigUpload) { + return true; + } + } + return false; + } + } + + /** + * A single registry record. Mutable while the table is being + * built (the fluent setters); semantically immutable once exposed + * via {@link #entries()}. + */ + public static final class Entry { + private final String classPrefix; + private final List iosPods = new ArrayList(); + private final List iosSpm = new ArrayList(); + private final List iosFrameworks = new ArrayList(); + private final List iosPlist = new ArrayList(); + private final List androidGradle = new ArrayList(); + private final List androidPermissions = new ArrayList(); + private final List androidFeatures = new ArrayList(); + private boolean requiresBigUpload; + private String description = ""; + + Entry(String classPrefix) { + this.classPrefix = classPrefix; + } + + boolean matches(String internalClassName) { + if (classPrefix.endsWith("/")) { + return internalClassName.startsWith(classPrefix); + } + return internalClassName.equals(classPrefix); + } + + Entry iosPod(String pod) { + iosPods.add(pod); + return this; + } + + Entry iosSpm(String identity, String url, String requirement, String... products) { + iosSpm.add(new IosSpm(identity, url, requirement, + Arrays.asList(products))); + return this; + } + + Entry iosFrameworks(String... fws) { + for (String f : fws) { + iosFrameworks.add(f); + } + return this; + } + + Entry iosPlist(String key, String defaultValue) { + iosPlist.add(new String[]{key, defaultValue}); + return this; + } + + Entry androidGradle(String gav) { + androidGradle.add(gav); + return this; + } + + Entry androidPermissions(String... perms) { + for (String p : perms) { + androidPermissions.add(p); + } + return this; + } + + Entry androidFeatures(String... feats) { + for (String f : feats) { + androidFeatures.add(f); + } + return this; + } + + Entry markBigUpload() { + this.requiresBigUpload = true; + return this; + } + + Entry description(String d) { + this.description = d; + return this; + } + + public String classPrefix() { + return classPrefix; + } + + public List iosPods() { + return Collections.unmodifiableList(iosPods); + } + + public List iosSpmSpecs() { + return Collections.unmodifiableList(iosSpm); + } + + public List iosFrameworks() { + return Collections.unmodifiableList(iosFrameworks); + } + + /** Each entry is {key, defaultValue}. The builder injects the + * value only if the app hasn't already declared one for the + * same key in its build hints. */ + public List iosPlistEntries() { + return Collections.unmodifiableList(iosPlist); + } + + public List androidGradleDeps() { + return Collections.unmodifiableList(androidGradle); + } + + public List androidPermissions() { + return Collections.unmodifiableList(androidPermissions); + } + + public List androidFeatures() { + return Collections.unmodifiableList(androidFeatures); + } + + public boolean requiresBigUpload() { + return requiresBigUpload; + } + + public String description() { + return description; + } + } + + /** Swift Package Manager dependency descriptor. */ + public static final class IosSpm { + public final String identity; + public final String url; + public final String requirement; + public final List products; + + IosSpm(String identity, String url, String requirement, List products) { + this.identity = identity; + this.url = url; + this.requirement = requirement; + this.products = Collections.unmodifiableList(new ArrayList(products)); + } + } +} diff --git a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java index 2da7599d96..8398b0d8bd 100644 --- a/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java +++ b/maven/codenameone-maven-plugin/src/main/java/com/codename1/builders/AndroidGradleBuilder.java @@ -500,7 +500,7 @@ private String getGradleJavaHome() throws BuildException { private static boolean currentJvmIsJava17OrLater() { String spec = System.getProperty("java.specification.version", ""); if (spec.startsWith("1.")) { - // 1.5 .. 1.8 era — definitely older than 17. + // 1.5 .. 1.8 era -- definitely older than 17. return false; } try { @@ -555,9 +555,9 @@ public boolean build(File sourceZip, final BuildRequest request) throws BuildExc // On-device debugging: when set, mark the APK debuggable so Dalvik/ART exposes // a JDWP socket the cn1:android-on-device-debugging Mojo can forward through adb. // Forcing debuggable also flips off R8 and ProGuard so symbols, method names, - // and line numbers survive the build — we may be invoked from the android-device + // and line numbers survive the build -- we may be invoked from the android-device // cloud target which would otherwise run full optimisation. Also force the build - // down to debug-only — Android otherwise produces both a release and a debug + // down to debug-only -- Android otherwise produces both a release and a debug // APK from the same manifest, so without this a stray hint could ship a // release-signed, debuggable APK. Release builds and projects that don't opt // in see no change. @@ -1224,12 +1224,20 @@ public boolean build(File sourceZip, final BuildRequest request) throws BuildExc wakeLock = true; } mediaPlaybackPermission = false; + + // Accumulator for AI/ML class hits. After the scan we apply + // every matched AiDependencyTable.Entry -- appending Gradle + // deps to additionalDependencies (later) and permissions/ + // features to xPermissions right now. + final AiDependencyTable.Accumulator aiAcc = new AiDependencyTable.Accumulator(); + try { scanClassesForPermissions(dummyClassesDir, new Executor.ClassScanner() { @Override public void usesClass(String cls) { + aiAcc.consume(cls); if (cls.indexOf("com/codename1/notifications") == 0) { recieveBootCompletedPermission = true; if (targetSDKVersionInt >= 33) { @@ -1427,6 +1435,32 @@ public void usesClassMethod(String cls, String method) { throw new BuildException("An error occurred while trying to scan the classes for API usage.", ex); } + // Apply AI/ML dependency table hits accumulated during the + // scan. Permissions / features go to xPermissions right + // away (so they're visible to all the downstream manifest + // assembly). Gradle deps are stashed in + // aiExtraGradleDependencies and appended just before + // additionalDependencies is written to build.gradle below. + StringBuilder aiExtraGradleDependencies = new StringBuilder(); + for (AiDependencyTable.Entry entry : aiAcc.hits()) { + for (String perm : entry.androidPermissions()) { + String addString = " \n"; + xPermissions += permissionAdd(request, perm, addString); + } + for (String feat : entry.androidFeatures()) { + String addString = " \n"; + if (!xPermissions.contains(" 0) spmPackages.append(';'); + spmPackages.append(spm.identity).append('|') + .append(spm.url).append('|') + .append(spm.requirement); + StringBuilder products = new StringBuilder(); + for (int i = 0; i < spm.products.size(); i++) { + if (i > 0) products.append(','); + products.append(spm.products.get(i)); + } + // Honor any user-declared products -- append, don't overwrite. + String existingProducts = request.getArg("ios.spm.products." + spm.identity, ""); + if (existingProducts != null && existingProducts.length() > 0) { + products.insert(0, existingProducts + ","); + } + request.putArgument("ios.spm.products." + spm.identity, products.toString()); + } + handledViaSpm = true; + } + if (!handledViaSpm) { + for (String pod : entry.iosPods()) { + if (iosPods.length() > 0) iosPods += ","; + iosPods += pod; + } + } + for (String[] plistEntry : entry.iosPlistEntries()) { + String key = "ios." + plistEntry[0]; + if (request.getArg(key, null) == null) { + request.putArgument(key, plistEntry[1]); + } + } + } + if (spmPackages.length() > 0) { + request.putArgument("ios.spm.packages", spmPackages.toString()); + } + // Surface the upload-size flag for the cloud build server + // so it can abort early with a friendly message. + if (aiAcc.anyRequiresBigUpload()) { + request.putArgument("cn1.ai.requiresBigUpload", "true"); + } + // Re-resolve in case AI deps pushed us into a different + // mode (e.g. pods-only-when-the-project-was-SPM-only). + dependencyConfig = IOSDependencyManager.resolve(request, iosPods); + iosPods = dependencyConfig.iosPods; + boolean newRunPods = dependencyConfig.usesCocoaPods(); + boolean newRunSpm = dependencyConfig.usesSwiftPackages(); + if (newRunPods && !runPods) { + ensurePodsInstalled(); + } + if (newRunSpm && !runSpm) { + ensureXcodeprojInstalled(); + } + runPods = newRunPods; + runSpm = newRunSpm; + } + debug("Local Notifications "+(usesLocalNotifications?"enabled":"disabled")); try { unzip(getResourceAsStream("/iOSPort.jar"), classesDir, buildinRes, buildinRes); @@ -803,7 +882,7 @@ public void usesClassMethod(String cls, String method) { new File(buildinRes, "CodenameOne_METALViewController.xib").delete(); // The .metal shader file isn't guarded by an #ifdef like the // companion .m files, so leaving it in the project forces Xcode - // to invoke the Metal toolchain — which Xcode 26 ships as a + // to invoke the Metal toolchain -- which Xcode 26 ships as a // separately-downloaded component that build servers don't have. new File(buildinRes, "CN1MetalShaders.metal").delete(); } diff --git a/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AiDependencyTableTest.java b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AiDependencyTableTest.java new file mode 100644 index 0000000000..9426803bd6 --- /dev/null +++ b/maven/codenameone-maven-plugin/src/test/java/com/codename1/builders/AiDependencyTableTest.java @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2012, Codename One and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Codename One designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Codename One through http://www.codenameone.com/ if you + * need additional information or have any questions. + */ +package com.codename1.builders; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class AiDependencyTableTest { + + @Test + void mlkitTextRecognizerMapsToPodAndGradleDep() { + List hits = AiDependencyTable.matchesFor( + "com/codename1/ai/mlkit/text/TextRecognizer"); + assertEquals(1, hits.size(), "expected one entry to fire"); + AiDependencyTable.Entry e = hits.get(0); + assertTrue(e.iosPods().contains("GoogleMLKit/TextRecognition")); + assertTrue(e.androidGradleDeps().get(0).startsWith("com.google.mlkit:text-recognition")); + // Camera plist string is injected because text recognition + // is virtually always used with the camera. + assertNotNull(findPlistDefault(e, "NSCameraUsageDescription")); + } + + @Test + void speechRecognizerInjectsMicAndSpeechPlist() { + List hits = AiDependencyTable.matchesFor( + "com/codename1/media/SpeechRecognizer"); + assertEquals(1, hits.size()); + AiDependencyTable.Entry e = hits.get(0); + assertTrue(e.iosFrameworks().contains("Speech")); + assertNotNull(findPlistDefault(e, "NSMicrophoneUsageDescription")); + assertNotNull(findPlistDefault(e, "NSSpeechRecognitionUsageDescription")); + assertTrue(e.androidPermissions().contains("android.permission.RECORD_AUDIO")); + } + + @Test + void textToSpeechInjectsNoPermissions() { + List hits = AiDependencyTable.matchesFor( + "com/codename1/media/TextToSpeech"); + assertEquals(1, hits.size()); + AiDependencyTable.Entry e = hits.get(0); + assertTrue(e.iosFrameworks().contains("AVFAudio")); + assertTrue(e.androidPermissions().isEmpty(), + "TTS is built-in on every supported OS -- no permission needed"); + assertTrue(e.iosPlistEntries().isEmpty(), + "TTS has no Apple-reviewed restricted entitlement"); + } + + @Test + void llmClientNeedsNothingExtra() { + // The LlmClient entries are intentionally cheap: pure HTTPS + // means no plist string, no extra permission. They still + // register so future diagnostics ("which AI APIs does this + // app use?") can enumerate them. + List hits = AiDependencyTable.matchesFor( + "com/codename1/ai/LlmClient"); + assertEquals(1, hits.size()); + AiDependencyTable.Entry e = hits.get(0); + assertTrue(e.iosPods().isEmpty()); + assertTrue(e.androidGradleDeps().isEmpty()); + assertTrue(e.androidPermissions().isEmpty()); + } + + @Test + void stableDiffusionFlagsBigUpload() { + AiDependencyTable.Accumulator acc = new AiDependencyTable.Accumulator(); + acc.consume("com/codename1/ai/imagegen/StableDiffusion"); + assertTrue(acc.anyRequiresBigUpload(), + "On-device SD ships a 1-2 GB Core ML model -- cloud builds must abort with a friendly message"); + } + + @Test + void mlkitDoesNotFlagBigUpload() { + AiDependencyTable.Accumulator acc = new AiDependencyTable.Accumulator(); + acc.consume("com/codename1/ai/mlkit/text/TextRecognizer"); + acc.consume("com/codename1/ai/mlkit/barcode/BarcodeScanner"); + acc.consume("com/codename1/ai/whisper/WhisperRecognizer"); + assertFalse(acc.anyRequiresBigUpload(), + "ML Kit models stream lazily, Whisper bundles a small static lib -- neither exceeds the 2 GB cap"); + } + + @Test + void unrelatedClassesProduceNoHits() { + // Sanity: we mustn't false-positive on classes outside the + // AI namespace, because the scanner walks every class in + // the user's app. + assertTrue(AiDependencyTable.matchesFor("com/codename1/ui/Form").isEmpty()); + assertTrue(AiDependencyTable.matchesFor("java/lang/Object").isEmpty()); + assertTrue(AiDependencyTable.matchesFor(null).isEmpty()); + } + + @Test + void tfliteHasBothPodAndSpmSpec() { + // TFLite is published as both a CocoaPod and a Swift Package. + // The table records both so projects can route the dep + // through whichever manager they prefer; the IPhoneBuilder + // applies whichever matches the project's current + // ios.dependencyManager setting. + List hits = AiDependencyTable.matchesFor( + "com/codename1/ai/tflite/Interpreter"); + assertEquals(1, hits.size()); + AiDependencyTable.Entry e = hits.get(0); + assertFalse(e.iosPods().isEmpty(), "expected a CocoaPods spec"); + assertFalse(e.iosSpmSpecs().isEmpty(), "expected an SPM spec"); + } + + @Test + void accumulatorDeduplicates() { + // Same class twice in the same scan shouldn't add the entry + // twice -- otherwise we'd inject duplicate Gradle / pod + // lines on the wire. + AiDependencyTable.Accumulator acc = new AiDependencyTable.Accumulator(); + acc.consume("com/codename1/ai/mlkit/text/TextRecognizer"); + acc.consume("com/codename1/ai/mlkit/text/OptionsBuilder"); + assertEquals(1, acc.hits().size()); + } + + private static String findPlistDefault(AiDependencyTable.Entry e, String key) { + for (String[] entry : e.iosPlistEntries()) { + if (key.equals(entry[0])) { + return entry[1]; + } + } + return null; + } +} diff --git a/maven/core-unittests/spotbugs-exclude.xml b/maven/core-unittests/spotbugs-exclude.xml index acfc0cca06..6586eb6349 100644 --- a/maven/core-unittests/spotbugs-exclude.xml +++ b/maven/core-unittests/spotbugs-exclude.xml @@ -138,6 +138,24 @@ + + + + + + + + + +