diff --git a/gradle.properties b/gradle.properties index 56824ca..38a33ca 100644 --- a/gradle.properties +++ b/gradle.properties @@ -6,7 +6,7 @@ minecraft_version=1.20.1 yarn_mappings=1.20.1+build.10 loader_version=0.18.4 # Mod Properties -mod_version=1.0-SNAPSHOT +mod_version=1.0.1+1.20.1 maven_group=AiCompanion archives_base_name=aicompanion2-0 # Dependencies diff --git a/src/client/java/AiCompanion/aicompanion2_0/client/AiChatSession.java b/src/client/java/AiCompanion/aicompanion2_0/client/AiChatSession.java index b244660..67f119a 100644 --- a/src/client/java/AiCompanion/aicompanion2_0/client/AiChatSession.java +++ b/src/client/java/AiCompanion/aicompanion2_0/client/AiChatSession.java @@ -12,12 +12,13 @@ public class AiChatSession { private final String apiBaseUrl; private final String apiKey; private final String model; + private final String apiPath; // null = auto-detect // OpenAI-format message history: alternating user/assistant private final List history = new ArrayList<>(); // [role, content] private final List displayLines = new ArrayList<>(); - public AiChatSession(String apiBaseUrl, String apiKey, String model) { + public AiChatSession(String apiBaseUrl, String apiKey, String model, String apiPath) { this.apiBaseUrl = apiBaseUrl; this.apiKey = apiKey; this.model = model; @@ -62,6 +63,15 @@ public class AiChatSession { messages.append("]"); String json = "{\"model\":\"" + jsonEscape(model) + "\",\"messages\":" + messages + ",\"stream\":false}"; + + if (apiPath != null) { + // User configured a specific path — use it directly, no fallback + HttpResult result = postChatCompletion(apiPath, json); + if (result.status == 200) return extractAssistantContent(result.body); + return formatHttpError(result.status, result.body, apiPath); + } + + // Auto-detect: try /api/chat/completions, fall back to /v1/chat/completions HttpResult primary = postChatCompletion("/api/chat/completions", json); if (primary.status == 200) { return extractAssistantContent(primary.body); diff --git a/src/client/java/AiCompanion/aicompanion2_0/client/AiCompanionClient.java b/src/client/java/AiCompanion/aicompanion2_0/client/AiCompanionClient.java index f4e8666..7b5072c 100644 --- a/src/client/java/AiCompanion/aicompanion2_0/client/AiCompanionClient.java +++ b/src/client/java/AiCompanion/aicompanion2_0/client/AiCompanionClient.java @@ -2,7 +2,10 @@ package AiCompanion.aicompanion2_0.client; import AiCompanion.aicompanion2_0.AIEntity; import AiCompanion.aicompanion2_0.Aicompanion2_0; +import com.mojang.brigadier.arguments.StringArgumentType; import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.command.v2.ClientCommandManager; +import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; import net.fabricmc.fabric.api.client.rendering.v1.EntityRendererRegistry; @@ -177,9 +180,10 @@ public class AiCompanionClient implements ClientModInitializer { private static void openChatScreen(MinecraftClient client) { if (currentSession == null) { currentSession = new AiChatSession( - Aicompanion2_0.getApiBaseUrl(), + ClientConfig.getBaseUrl(), ClientConfig.getApiKey(), - Aicompanion2_0.getModel() + ClientConfig.getModel(), + ClientConfig.getApiPath() ); } client.setScreen(new AiChatScreen(currentSession)); diff --git a/src/client/java/AiCompanion/aicompanion2_0/client/ClientConfig.java b/src/client/java/AiCompanion/aicompanion2_0/client/ClientConfig.java index 73ba57b..2bda87b 100644 --- a/src/client/java/AiCompanion/aicompanion2_0/client/ClientConfig.java +++ b/src/client/java/AiCompanion/aicompanion2_0/client/ClientConfig.java @@ -8,7 +8,12 @@ public class ClientConfig { private static final Path CONFIG_PATH = Path.of("config", "aicompanion2_0_client.properties"); private static final Path SHARED_CONFIG_PATH = Path.of("config", "aicompanion2_0.properties"); + private static String apiKey = null; + private static String baseUrl = null; + private static String model = null; + private static String apiPath = null; + private static boolean loaded = false; public static String getApiKey() { load(); @@ -32,12 +37,53 @@ public class ClientConfig { return apiKey != null && !apiKey.isBlank(); } + public static String getBaseUrl() { + if (!loaded) load(); + return baseUrl; + } + + public static String getModel() { + if (!loaded) load(); + return model; + } + + public static String getApiPath() { + if (!loaded) load(); + return apiPath; + } + + public static boolean isSetupDone() { + if (!loaded) load(); + return baseUrl != null && !baseUrl.isBlank() && model != null && !model.isBlank(); + } + + public static void setProviderConfig(String url, String mdl, String key, String path) { + baseUrl = url; + model = mdl; + apiKey = key != null ? key : ""; + apiPath = (path != null && !path.isBlank()) ? path : null; + loaded = true; + save(); + } + private static void load() { + loaded = true; try { - // Prefer shared config so GUI and /ai frage use the same key source. - apiKey = loadApiKeyFrom(SHARED_CONFIG_PATH); + apiKey = loadProp(SHARED_CONFIG_PATH, "api.key"); if (apiKey == null || apiKey.isBlank()) { - apiKey = loadApiKeyFrom(CONFIG_PATH); + apiKey = loadProp(CONFIG_PATH, "api.key"); + } + baseUrl = loadProp(SHARED_CONFIG_PATH, "api.baseUrl"); + if (baseUrl == null || baseUrl.isBlank()) { + baseUrl = loadProp(CONFIG_PATH, "api.baseUrl"); + } + model = loadProp(SHARED_CONFIG_PATH, "api.model"); + if (model == null || model.isBlank()) { + model = loadProp(CONFIG_PATH, "api.model"); + } + apiPath = loadProp(SHARED_CONFIG_PATH, "api.path"); + if (apiPath == null || apiPath.isBlank()) { + apiPath = loadProp(CONFIG_PATH, "api.path"); } } catch (IOException e) { apiKey = null; @@ -60,10 +106,19 @@ public class ClientConfig { try (FileInputStream in = new FileInputStream(path.toFile())) { props.load(in); } - return props.getProperty("api.key", null); + return props.getProperty(key, null); } - private static void saveApiKeyTo(Path path, String comment) throws IOException { + private static void save() { + try { + saveTo(CONFIG_PATH, "AI Companion Client Config"); + saveTo(SHARED_CONFIG_PATH, "AI Companion Shared Config"); + } catch (IOException e) { + System.out.println("[aicompanion2_0] Konnte Client-Config nicht speichern."); + } + } + + private static void saveTo(Path path, String comment) throws IOException { Files.createDirectories(path.getParent()); Properties props = new Properties(); if (Files.exists(path)) { @@ -72,6 +127,9 @@ public class ClientConfig { } } props.setProperty("api.key", apiKey != null ? apiKey : ""); + if (baseUrl != null) props.setProperty("api.baseUrl", baseUrl); + if (model != null) props.setProperty("api.model", model); + if (apiPath != null) props.setProperty("api.path", apiPath); try (FileOutputStream out = new FileOutputStream(path.toFile())) { props.store(out, comment); } diff --git a/src/client/java/AiCompanion/aicompanion2_0/client/ProviderSetupScreen.java b/src/client/java/AiCompanion/aicompanion2_0/client/ProviderSetupScreen.java new file mode 100644 index 0000000..ae3c0d4 --- /dev/null +++ b/src/client/java/AiCompanion/aicompanion2_0/client/ProviderSetupScreen.java @@ -0,0 +1,360 @@ +package AiCompanion.aicompanion2_0.client; + +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.client.gui.widget.TextFieldWidget; +import net.minecraft.text.Text; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; + +public class ProviderSetupScreen extends Screen { + + private boolean configStep = false; + private boolean advancedExpanded = false; + private String provider = "ollama"; + + // Field values preserved across reinit() calls + private String savedUrl = ""; + private String savedModel = ""; + private String savedKey = ""; + private String savedPath = ""; + + // Model fetch state + private enum FetchState { IDLE, LOADING, SUCCESS, ERROR } + private FetchState fetchState = FetchState.IDLE; + private List fetchedModels = new ArrayList<>(); + private String fetchError = null; + private int modelScrollOffset = 0; + + private TextFieldWidget urlField; + private TextFieldWidget modelField; + private TextFieldWidget keyField; + private TextFieldWidget pathField; + + private final Runnable onSuccess; + + // Layout constants (relative to baseY) + private static final int URL_FIELD_Y = 10; + private static final int KEY_LABEL_Y = 37; + private static final int KEY_FIELD_Y = 47; + private static final int MODEL_LABEL_Y = 74; + private static final int MODEL_CONTENT_Y = 86; + private static final int ITEM_HEIGHT = 14; + private static final int MAX_VISIBLE = 4; + + public ProviderSetupScreen(Runnable onSuccess) { + super(Text.literal("AI Companion Setup")); + this.onSuccess = onSuccess; + } + + @Override + protected void init() { + int cx = width / 2; + int baseY = height / 2 - 75; + + if (!configStep) { + int cy = height / 2; + addDrawableChild(ButtonWidget.builder(Text.literal("Ollama (Local)"), btn -> goToConfig("ollama")) + .dimensions(cx - 155, cy, 100, 20).build()); + addDrawableChild(ButtonWidget.builder(Text.literal("Open-WebUI"), btn -> goToConfig("openwebui")) + .dimensions(cx - 50, cy, 100, 20).build()); + addDrawableChild(ButtonWidget.builder(Text.literal("Custom"), btn -> goToConfig("custom")) + .dimensions(cx + 55, cy, 100, 20).build()); + return; + } + + // --- Config step --- + + // URL field + urlField = new TextFieldWidget(textRenderer, cx - 150, baseY + URL_FIELD_Y, 300, 20, Text.literal("http://...")); + urlField.setMaxLength(200); + urlField.setText(savedUrl.isEmpty() ? defaultUrl() : savedUrl); + addDrawableChild(urlField); + + // Key field + keyField = new TextFieldWidget(textRenderer, cx - 150, baseY + KEY_FIELD_Y, 300, 20, Text.literal("API Key (optional)")); + keyField.setMaxLength(200); + keyField.setText(savedKey); + addDrawableChild(keyField); + + // Fetch Models button (right side of model row header) + String fetchLabel = switch (fetchState) { + case LOADING -> "§7Fetching..."; + case SUCCESS -> "↻ Refetch"; + case ERROR -> "↻ Retry"; + default -> "Fetch Models"; + }; + addDrawableChild(ButtonWidget.builder(Text.literal(fetchLabel), btn -> { + if (fetchState != FetchState.LOADING) { + saveCurrentValues(); + fetchModels(); + } + }).dimensions(cx + 5, baseY + MODEL_LABEL_Y, 145, 12).build()); + + // Model content area: clickable list OR text field + boolean hasModelList = fetchState == FetchState.SUCCESS && !fetchedModels.isEmpty(); + int listHeight; + + if (hasModelList) { + int visibleCount = Math.min(fetchedModels.size(), MAX_VISIBLE); + int endIdx = Math.min(fetchedModels.size(), modelScrollOffset + visibleCount); + listHeight = visibleCount * ITEM_HEIGHT; + + for (int i = modelScrollOffset; i < endIdx; i++) { + String m = fetchedModels.get(i); + boolean selected = m.equals(savedModel); + final String model = m; + addDrawableChild(ButtonWidget.builder( + Text.literal((selected ? "§a▶ " : " ") + m), + btn -> { savedModel = model; reinit(); } + ).dimensions(cx - 150, baseY + MODEL_CONTENT_Y + (i - modelScrollOffset) * ITEM_HEIGHT, 290, ITEM_HEIGHT - 2).build()); + } + + // Up/down scroll when list is longer than visible + if (fetchedModels.size() > MAX_VISIBLE) { + addDrawableChild(ButtonWidget.builder(Text.literal("▲"), + btn -> { modelScrollOffset = Math.max(0, modelScrollOffset - 1); reinit(); } + ).dimensions(cx + 143, baseY + MODEL_CONTENT_Y, 7, ITEM_HEIGHT - 2).build()); + addDrawableChild(ButtonWidget.builder(Text.literal("▼"), + btn -> { modelScrollOffset = Math.min(fetchedModels.size() - MAX_VISIBLE, modelScrollOffset + 1); reinit(); } + ).dimensions(cx + 143, baseY + MODEL_CONTENT_Y + ITEM_HEIGHT, 7, ITEM_HEIGHT - 2).build()); + } + } else { + // Manual text input fallback + modelField = new TextFieldWidget(textRenderer, cx - 150, baseY + MODEL_CONTENT_Y, 300, 20, Text.literal("Model name...")); + modelField.setMaxLength(100); + modelField.setText(savedModel.isEmpty() ? defaultModel() : savedModel); + addDrawableChild(modelField); + listHeight = 22; + } + + // Positions below model area + int afterModelY = baseY + MODEL_CONTENT_Y + listHeight + 5; + + // Advanced toggle + String advLabel = advancedExpanded ? "Advanced ▲" : "Advanced ▼"; + addDrawableChild(ButtonWidget.builder(Text.literal("§8" + advLabel), btn -> { + saveCurrentValues(); + advancedExpanded = !advancedExpanded; + reinit(); + }).dimensions(cx + 55, afterModelY, 95, 12).build()); + + int buttonsY; + if (advancedExpanded) { + pathField = new TextFieldWidget(textRenderer, cx - 150, afterModelY + 18, 300, 20, Text.literal("/api/chat/completions")); + pathField.setMaxLength(200); + pathField.setText(savedPath.isEmpty() ? defaultPath() : savedPath); + addDrawableChild(pathField); + buttonsY = afterModelY + 45; + } else { + buttonsY = afterModelY + 18; + } + + addDrawableChild(ButtonWidget.builder(Text.literal("< Back"), btn -> { + configStep = false; + advancedExpanded = false; + fetchState = FetchState.IDLE; + fetchedModels = new ArrayList<>(); + savedUrl = savedModel = savedKey = savedPath = ""; + reinit(); + }).dimensions(cx - 155, buttonsY, 70, 20).build()); + + addDrawableChild(ButtonWidget.builder(Text.literal("Confirm"), btn -> confirm()) + .dimensions(cx - 75, buttonsY, 150, 20).build()); + + urlField.setFocused(true); + } + + private void saveCurrentValues() { + if (urlField != null) savedUrl = urlField.getText(); + if (modelField != null) savedModel = modelField.getText(); + if (keyField != null) savedKey = keyField.getText(); + if (pathField != null) savedPath = pathField.getText(); + } + + private void goToConfig(String p) { + this.provider = p; + this.configStep = true; + reinit(); + } + + private void reinit() { + MinecraftClient client = MinecraftClient.getInstance(); + this.init(client, client.getWindow().getScaledWidth(), client.getWindow().getScaledHeight()); + } + + private String defaultUrl() { + return switch (provider) { + case "ollama" -> "http://localhost:11434"; + case "openwebui" -> "https://ai.cametendo.org"; + default -> ""; + }; + } + + private String defaultModel() { + return switch (provider) { + case "openwebui" -> "minecraft-helper"; + default -> ""; + }; + } + + private String defaultPath() { + return switch (provider) { + case "ollama" -> "/v1/chat/completions"; + case "openwebui" -> "/api/chat/completions"; + default -> ""; + }; + } + + // --- Model fetching --- + + private void fetchModels() { + fetchState = FetchState.LOADING; + fetchError = null; + String url = savedUrl.isEmpty() ? defaultUrl() : savedUrl; + String key = savedKey; + reinit(); + + new Thread(() -> { + try { + List models = doFetchModels(url, key); + if (models.isEmpty()) { + fetchState = FetchState.ERROR; + fetchError = "No models found at " + url; + } else { + fetchedModels = models; + fetchState = FetchState.SUCCESS; + modelScrollOffset = 0; + if (savedModel.isEmpty()) savedModel = models.get(0); + } + } catch (Exception e) { + fetchState = FetchState.ERROR; + fetchError = e.getMessage() != null ? e.getMessage() : "Connection failed"; + } + MinecraftClient.getInstance().execute(this::reinit); + }).start(); + } + + private List doFetchModels(String baseUrl, String apiKey) throws Exception { + // Try Ollama native: GET /api/tags → {"models": [{"name": "..."}]} + try { + List models = fetchFromPath(baseUrl, "/api/tags", apiKey, "name"); + if (!models.isEmpty()) return models; + } catch (Exception ignored) {} + + // Try OpenAI-compat: GET /v1/models → {"data": [{"id": "..."}]} + return fetchFromPath(baseUrl, "/v1/models", apiKey, "id"); + } + + private List fetchFromPath(String baseUrl, String path, String apiKey, String nameField) throws Exception { + URL url = URI.create(baseUrl + path).toURL(); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(5000); + conn.setReadTimeout(5000); + if (apiKey != null && !apiKey.isBlank()) { + conn.setRequestProperty("Authorization", "Bearer " + apiKey); + } + + int status = conn.getResponseCode(); + if (status != 200) throw new Exception("HTTP " + status + " from " + path); + + StringBuilder sb = new StringBuilder(); + try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream(), "utf-8"))) { + String line; + while ((line = br.readLine()) != null) sb.append(line); + } + conn.disconnect(); + + // Simple field extraction: find all occurrences of "nameField":"value" + List result = new ArrayList<>(); + String search = "\"" + nameField + "\":\""; + String body = sb.toString(); + int idx = 0; + while ((idx = body.indexOf(search, idx)) >= 0) { + int start = idx + search.length(); + int end = body.indexOf("\"", start); + if (end > start) result.add(body.substring(start, end)); + idx = end + 1; + } + return result; + } + + // --- Confirm --- + + private void confirm() { + saveCurrentValues(); + String url = savedUrl.trim(); + String model = savedModel.trim(); + String key = savedKey.trim(); + String path = advancedExpanded ? savedPath.trim() : ""; + if (url.isEmpty() || model.isEmpty()) return; + ClientConfig.setProviderConfig(url, model, key.isEmpty() ? null : key, path.isEmpty() ? null : path); + MinecraftClient.getInstance().execute(() -> { + close(); + onSuccess.run(); + }); + } + + @Override + public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + if (configStep && (keyCode == 257 || keyCode == 335)) { // Enter + confirm(); + return true; + } + return super.keyPressed(keyCode, scanCode, modifiers); + } + + @Override + public void render(DrawContext context, int mouseX, int mouseY, float delta) { + renderBackground(context); + int cx = width / 2; + int baseY = height / 2 - 75; + + if (!configStep) { + int cy = height / 2; + context.drawCenteredTextWithShadow(textRenderer, "§6AI Companion Setup", cx, cy - 30, 0xFFFFFF); + context.drawCenteredTextWithShadow(textRenderer, "§fChoose your AI provider:", cx, cy - 15, 0xAAAAAA); + } else { + String title = switch (provider) { + case "ollama" -> "§6Configure Ollama"; + case "openwebui" -> "§6Configure Open-WebUI"; + default -> "§6Custom Configuration"; + }; + context.drawCenteredTextWithShadow(textRenderer, title, cx, baseY - 5, 0xFFFFFF); + context.drawTextWithShadow(textRenderer, "§7Server URL:", cx - 150, baseY, 0xAAAAAA); + context.drawTextWithShadow(textRenderer, "§7API Key §8(optional):", cx - 150, baseY + KEY_LABEL_Y, 0xAAAAAA); + context.drawTextWithShadow(textRenderer, "§7Model:", cx - 150, baseY + MODEL_LABEL_Y, 0xAAAAAA); + + // Fetch status / error below model label + if (fetchState == FetchState.LOADING) { + context.drawTextWithShadow(textRenderer, "§7Loading...", cx - 150, baseY + MODEL_CONTENT_Y + 4, 0xAAAAAA); + } else if (fetchState == FetchState.ERROR && fetchError != null) { + context.drawTextWithShadow(textRenderer, "§c" + fetchError, cx - 150, baseY + MODEL_CONTENT_Y + 4, 0xFF5555); + } + + // Advanced path label + if (advancedExpanded) { + boolean hasModelList = fetchState == FetchState.SUCCESS && !fetchedModels.isEmpty(); + int listHeight = hasModelList ? Math.min(fetchedModels.size(), MAX_VISIBLE) * ITEM_HEIGHT : 22; + int afterModelY = baseY + MODEL_CONTENT_Y + listHeight + 5; + context.drawTextWithShadow(textRenderer, "§7API Path:", cx - 150, afterModelY + 8, 0xAAAAAA); + } + } + + super.render(context, mouseX, mouseY, delta); + } + + @Override + public boolean shouldPause() { + return false; + } +} diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index 1f2ef0a..419a1cc 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "id": "aicompanion2_0", - "version": "1.0.0", + "version": "1.0.1+1.20.1", "name": "AI Companion 2.0", "description": "AI Chatbot powered by Ollama", "authors": ["Du"],