From 97734483fdaa3b0c45dc576d4c999884496ff3ee Mon Sep 17 00:00:00 2001 From: Cametendo Date: Wed, 4 Mar 2026 21:36:09 +0100 Subject: [PATCH] Add entity and connection to open-webui --- gradlew | 0 .../aicompanion2_0/client/AiChatScreen.java | 116 +++++++ .../aicompanion2_0/client/AiChatSession.java | 144 +++++++++ .../client/AiCompanionClient.java | 74 +++++ .../aicompanion2_0/client/ApiKeyScreen.java | 70 +++++ .../aicompanion2_0/client/ClientConfig.java | 71 +++++ .../AiCompanion/aicompanion2_0/AIEntity.java | 82 +++++ .../aicompanion2_0/Aicompanion2_0.java | 295 +++++++++++++----- src/main/resources/aicompanion2_0.mixins.json | 10 + .../assets/aicompanion2_0/lang/de_de.json | 1 + .../assets/aicompanion2_0/lang/en_us.json | 1 + src/main/resources/fabric.mod.json | 7 +- 12 files changed, 791 insertions(+), 80 deletions(-) mode change 100644 => 100755 gradlew create mode 100644 src/client/java/AiCompanion/aicompanion2_0/client/AiChatScreen.java create mode 100644 src/client/java/AiCompanion/aicompanion2_0/client/AiChatSession.java create mode 100644 src/client/java/AiCompanion/aicompanion2_0/client/AiCompanionClient.java create mode 100644 src/client/java/AiCompanion/aicompanion2_0/client/ApiKeyScreen.java create mode 100644 src/client/java/AiCompanion/aicompanion2_0/client/ClientConfig.java create mode 100644 src/main/java/AiCompanion/aicompanion2_0/AIEntity.java create mode 100644 src/main/resources/aicompanion2_0.mixins.json create mode 100644 src/main/resources/assets/aicompanion2_0/lang/de_de.json create mode 100644 src/main/resources/assets/aicompanion2_0/lang/en_us.json diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/client/java/AiCompanion/aicompanion2_0/client/AiChatScreen.java b/src/client/java/AiCompanion/aicompanion2_0/client/AiChatScreen.java new file mode 100644 index 0000000..74d501f --- /dev/null +++ b/src/client/java/AiCompanion/aicompanion2_0/client/AiChatScreen.java @@ -0,0 +1,116 @@ +package AiCompanion.aicompanion2_0.client; + +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.OrderedText; +import net.minecraft.text.Text; + +import java.util.ArrayList; +import java.util.List; + +public class AiChatScreen extends Screen { + + private final AiChatSession session; + private TextFieldWidget inputField; + private int scrollOffset = 0; + + public AiChatScreen(AiChatSession session) { + super(Text.literal("AI Companion")); + this.session = session; + } + + private List displayLines() { + return session.getDisplayLines(); + } + + @Override + protected void init() { + inputField = new TextFieldWidget( + textRenderer, width / 2 - 150, height - 35, 270, 20, + Text.literal("Nachricht...") + ); + inputField.setMaxLength(500); + inputField.setFocused(true); + addDrawableChild(inputField); + + addDrawableChild(ButtonWidget.builder(Text.literal("Senden"), btn -> sendMessage()) + .dimensions(width / 2 + 125, height - 35, 60, 20) + .build() + ); + + addDrawableChild(ButtonWidget.builder(Text.literal("Schließen"), btn -> close()) + .dimensions(width / 2 - 30, height - 10, 60, 15) + .build() + ); + } + + @Override + public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + if (keyCode == 257 || keyCode == 335) { // Enter + sendMessage(); + return true; + } + return super.keyPressed(keyCode, scanCode, modifiers); + } + + private void sendMessage() { + String msg = inputField.getText().trim(); + if (msg.isEmpty()) return; + inputField.setText(""); + + displayLines().add("§eIch: §f" + msg); + displayLines().add("§7[AI denkt nach...]"); + session.sendMessage(msg, response -> { + displayLines().remove("§7[AI denkt nach...]"); + displayLines().add("§6AI: §f" + response); + // auto-scroll to bottom + scrollOffset = 0; + }); + } + + @Override + public void render(DrawContext context, int mouseX, int mouseY, float delta) { + renderBackground(context); + + // Chat box background + context.fill(width / 2 - 155, 10, width / 2 + 155, height - 45, 0xAA000000); + + // Title + context.drawCenteredTextWithShadow(textRenderer, "§6AI Companion", width / 2, 15, 0xFFFFFF); + + // Pre-wrap all lines first so we know total height + int lineHeight = 11; + List allWrapped = new ArrayList<>(); + for (String dl : displayLines()) { + allWrapped.addAll(textRenderer.wrapLines(Text.literal(dl), 290)); + } + + int chatAreaHeight = height - 65; + int maxLines = chatAreaHeight / lineHeight; + int totalLines = allWrapped.size(); + + int startIdx = Math.max(0, totalLines - maxLines - scrollOffset); + int endIdx = Math.min(totalLines, startIdx + maxLines); + + int y = 25; + for (int i = startIdx; i < endIdx; i++) { + context.drawTextWithShadow(textRenderer, allWrapped.get(i), width / 2 - 148, y, 0xFFFFFF); + y += lineHeight; + } + + super.render(context, mouseX, mouseY, delta); + } + + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double amount) { + scrollOffset = Math.max(0, scrollOffset - (int) amount); + return true; + } + + @Override + public boolean shouldPause() { + return false; + } +} \ No newline at end of file diff --git a/src/client/java/AiCompanion/aicompanion2_0/client/AiChatSession.java b/src/client/java/AiCompanion/aicompanion2_0/client/AiChatSession.java new file mode 100644 index 0000000..6758072 --- /dev/null +++ b/src/client/java/AiCompanion/aicompanion2_0/client/AiChatSession.java @@ -0,0 +1,144 @@ +package AiCompanion.aicompanion2_0.client; + +import java.io.*; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; + +public class AiChatSession { + + private final String apiBaseUrl; + private final String apiKey; + private final String model; + + // 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) { + this.apiBaseUrl = apiBaseUrl; + this.apiKey = apiKey; + this.model = model; + displayLines.add("§7Starte eine Unterhaltung mit deinem AI-Begleiter!"); + } + + public List getDisplayLines() { + return displayLines; + } + + public void sendMessage(String userMessage, Consumer onResponse) { + history.add(new String[]{"user", userMessage}); + + new Thread(() -> { + try { + String response = callOpenWebUI(); + history.add(new String[]{"assistant", response}); + onResponse.accept(response); + } catch (Exception e) { + onResponse.accept("§cFehler: " + e.getMessage()); + } + }).start(); + } + + private String callOpenWebUI() throws Exception { + // Build messages array + StringBuilder messages = new StringBuilder("["); + for (int i = 0; i < history.size(); i++) { + String[] msg = history.get(i); + messages.append("{\"role\":\"").append(msg[0]) + .append("\",\"content\":\"").append(jsonEscape(msg[1])) + .append("\"}"); + if (i < history.size() - 1) messages.append(","); + } + messages.append("]"); + + String json = "{\"model\":\"" + jsonEscape(model) + "\",\"messages\":" + messages + ",\"stream\":false}"; + HttpResult primary = postChatCompletion("/api/chat/completions", json); + if (primary.status == 200) { + return extractAssistantContent(primary.body); + } + + if (primary.status >= 500 || primary.status == 404) { + HttpResult fallback = postChatCompletion("/v1/chat/completions", json); + if (fallback.status == 200) { + return extractAssistantContent(fallback.body); + } + return formatHttpError(fallback.status, fallback.body, "/v1/chat/completions"); + } + + return formatHttpError(primary.status, primary.body, "/api/chat/completions"); + } + + private HttpResult postChatCompletion(String path, String json) throws Exception { + URL url = new URL(apiBaseUrl + path); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json; charset=utf-8"); + if (apiKey != null && !apiKey.isBlank()) { + conn.setRequestProperty("Authorization", "Bearer " + apiKey); + } + conn.setDoOutput(true); + + try (OutputStream os = conn.getOutputStream()) { + os.write(json.getBytes("utf-8")); + } + + int status = conn.getResponseCode(); + String body = readResponseBody(conn, status >= 400); + conn.disconnect(); + return new HttpResult(status, body); + } + + private String readResponseBody(HttpURLConnection conn, boolean error) throws IOException { + var stream = error ? conn.getErrorStream() : conn.getInputStream(); + if (stream == null) return ""; + + StringBuilder resp = new StringBuilder(); + try (BufferedReader br = new BufferedReader(new InputStreamReader(stream, "utf-8"))) { + String line; + while ((line = br.readLine()) != null) resp.append(line); + } + return resp.toString(); + } + + private String extractAssistantContent(String body) { + int idx = body.indexOf("\"content\":\""); + if (idx >= 0) { + int start = idx + 11; + int end = body.indexOf("\"", start); + while (end > 0 && body.charAt(end - 1) == '\\') { + end = body.indexOf("\"", end + 1); + } + return body.substring(start, end).replace("\\n", "\n").replace("\\\"", "\""); + } + return body; + } + + private String formatHttpError(int status, String body, String path) { + if (body == null || body.isBlank()) { + return "Fehler: HTTP " + status + " (" + path + ")"; + } + return "Fehler: HTTP " + status + " (" + path + ") - " + body; + } + + private String jsonEscape(String value) { + if (value == null) return ""; + return value.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } + + private static class HttpResult { + private final int status; + private final String body; + + private HttpResult(int status, String body) { + this.status = status; + this.body = body; + } + } +} diff --git a/src/client/java/AiCompanion/aicompanion2_0/client/AiCompanionClient.java b/src/client/java/AiCompanion/aicompanion2_0/client/AiCompanionClient.java new file mode 100644 index 0000000..da72c3a --- /dev/null +++ b/src/client/java/AiCompanion/aicompanion2_0/client/AiCompanionClient.java @@ -0,0 +1,74 @@ +package AiCompanion.aicompanion2_0.client; + +import AiCompanion.aicompanion2_0.AIEntity; +import AiCompanion.aicompanion2_0.Aicompanion2_0; +import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; +import net.fabricmc.fabric.api.client.rendering.v1.EntityRendererRegistry; +import net.fabricmc.fabric.api.event.player.UseEntityCallback; +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.render.entity.BipedEntityRenderer; +import net.minecraft.client.render.entity.model.EntityModelLayers; +import net.minecraft.client.render.entity.model.PlayerEntityModel; +import net.minecraft.util.ActionResult; +import net.minecraft.util.Identifier; + +public class AiCompanionClient implements ClientModInitializer { + + // One session per world load, shared across all right-clicks + private static AiChatSession currentSession = null; + + @Override + public void onInitializeClient() { + // Register renderer + EntityRendererRegistry.register(Aicompanion2_0.AI_COMPANION, (context) -> + new BipedEntityRenderer>( + context, + new PlayerEntityModel<>(context.getPart(EntityModelLayers.PLAYER), false), + 0.5f + ) { + @Override + public Identifier getTexture(AIEntity entity) { + return new Identifier("aicompanion2_0", "textures/entity/skin.png"); + } + } + ); + + // Clear session when leaving a world + ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> { + currentSession = null; + }); + + // Open chat GUI on right-click + UseEntityCallback.EVENT.register((player, world, hand, entity, hitResult) -> { + if (world.isClient() && entity instanceof AIEntity) { + MinecraftClient client = MinecraftClient.getInstance(); + + client.execute(() -> { + if (!ClientConfig.hasApiKey()) { + // First time: ask for API key, then open chat + client.setScreen(new ApiKeyScreen(() -> { + currentSession = null; // reset so session uses new key + openChatScreen(client); + })); + } else { + openChatScreen(client); + } + }); + return ActionResult.SUCCESS; + } + return ActionResult.PASS; + }); + } + + private static void openChatScreen(MinecraftClient client) { + if (currentSession == null) { + currentSession = new AiChatSession( + Aicompanion2_0.getApiBaseUrl(), + ClientConfig.getApiKey(), + Aicompanion2_0.getModel() + ); + } + client.setScreen(new AiChatScreen(currentSession)); + } +} diff --git a/src/client/java/AiCompanion/aicompanion2_0/client/ApiKeyScreen.java b/src/client/java/AiCompanion/aicompanion2_0/client/ApiKeyScreen.java new file mode 100644 index 0000000..1d95d40 --- /dev/null +++ b/src/client/java/AiCompanion/aicompanion2_0/client/ApiKeyScreen.java @@ -0,0 +1,70 @@ +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; + +public class ApiKeyScreen extends Screen { + + private TextFieldWidget keyField; + private final Runnable onSuccess; + + public ApiKeyScreen(Runnable onSuccess) { + super(Text.literal("API Key eingeben")); + this.onSuccess = onSuccess; + } + + @Override + protected void init() { + keyField = new TextFieldWidget( + textRenderer, width / 2 - 150, height / 2 - 10, 300, 20, + Text.literal("API Key...") + ); + keyField.setMaxLength(200); + keyField.setFocused(true); + addDrawableChild(keyField); + + addDrawableChild(ButtonWidget.builder(Text.literal("Bestätigen"), btn -> confirm()) + .dimensions(width / 2 - 75, height / 2 + 20, 150, 20) + .build() + ); + } + + @Override + public boolean keyPressed(int keyCode, int scanCode, int modifiers) { + if (keyCode == 257 || keyCode == 335) { // Enter + confirm(); + return true; + } + return super.keyPressed(keyCode, scanCode, modifiers); + } + + private void confirm() { + String key = keyField.getText().trim(); + if (key.isEmpty()) return; + + ClientConfig.setApiKey(key); + MinecraftClient.getInstance().execute(() -> { + close(); + onSuccess.run(); + }); + } + + @Override + public void render(DrawContext context, int mouseX, int mouseY, float delta) { + renderBackground(context); + context.drawCenteredTextWithShadow(textRenderer, + "§6Bitte gib deinen Open-WebUI API Key ein:", width / 2, height / 2 - 30, 0xFFFFFF); + context.drawCenteredTextWithShadow(textRenderer, + "§7(Wird lokal gespeichert, nur einmalig nötig)", width / 2, height / 2 - 20, 0xAAAAAA); + super.render(context, mouseX, mouseY, delta); + } + + @Override + public boolean shouldPause() { + return false; + } +} \ No newline at end of file diff --git a/src/client/java/AiCompanion/aicompanion2_0/client/ClientConfig.java b/src/client/java/AiCompanion/aicompanion2_0/client/ClientConfig.java new file mode 100644 index 0000000..cc3720e --- /dev/null +++ b/src/client/java/AiCompanion/aicompanion2_0/client/ClientConfig.java @@ -0,0 +1,71 @@ +package AiCompanion.aicompanion2_0.client; + +import java.io.*; +import java.nio.file.*; +import java.util.Properties; + +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; + + public static String getApiKey() { + if (apiKey == null) load(); + return apiKey; + } + + public static void setApiKey(String key) { + apiKey = key; + save(); + } + + public static boolean hasApiKey() { + if (apiKey == null) load(); + return apiKey != null && !apiKey.isBlank(); + } + + private static void load() { + try { + // Prefer shared config so GUI and /ai frage use the same key source. + apiKey = loadApiKeyFrom(SHARED_CONFIG_PATH); + if (apiKey == null || apiKey.isBlank()) { + apiKey = loadApiKeyFrom(CONFIG_PATH); + } + } catch (IOException e) { + System.out.println("[aicompanion2_0] Konnte Client-Config nicht laden."); + } + } + + private static void save() { + try { + saveApiKeyTo(CONFIG_PATH, "AI Companion Client Config"); + saveApiKeyTo(SHARED_CONFIG_PATH, "AI Companion Shared Config"); + } catch (IOException e) { + System.out.println("[aicompanion2_0] Konnte Client-Config nicht speichern."); + } + } + + private static String loadApiKeyFrom(Path path) throws IOException { + if (!Files.exists(path)) return null; + Properties props = new Properties(); + try (FileInputStream in = new FileInputStream(path.toFile())) { + props.load(in); + } + return props.getProperty("api.key", null); + } + + private static void saveApiKeyTo(Path path, String comment) throws IOException { + Files.createDirectories(path.getParent()); + Properties props = new Properties(); + if (Files.exists(path)) { + try (FileInputStream in = new FileInputStream(path.toFile())) { + props.load(in); + } + } + props.setProperty("api.key", apiKey != null ? apiKey : ""); + try (FileOutputStream out = new FileOutputStream(path.toFile())) { + props.store(out, comment); + } + } +} diff --git a/src/main/java/AiCompanion/aicompanion2_0/AIEntity.java b/src/main/java/AiCompanion/aicompanion2_0/AIEntity.java new file mode 100644 index 0000000..6bb63ca --- /dev/null +++ b/src/main/java/AiCompanion/aicompanion2_0/AIEntity.java @@ -0,0 +1,82 @@ +package AiCompanion.aicompanion2_0; + +import net.minecraft.entity.EntityType; +import net.minecraft.entity.ai.goal.*; +import net.minecraft.entity.ai.pathing.MobNavigation; +import net.minecraft.entity.attribute.DefaultAttributeContainer; +import net.minecraft.entity.attribute.EntityAttributes; +import net.minecraft.entity.damage.DamageSource; +import net.minecraft.entity.passive.PassiveEntity; +import net.minecraft.entity.passive.TameableEntity; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.item.ItemStack; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.text.Text; +import net.minecraft.world.EntityView; +import net.minecraft.world.World; +import org.jetbrains.annotations.Nullable; + +public class AIEntity extends TameableEntity { + + public AIEntity(EntityType entityType, World world) { + super(entityType, world); + this.setInvulnerable(true); + this.setCustomName(Text.translatable("entity.aicompanion2_0.ai_companion")); + this.setCustomNameVisible(true); + // Ensure navigation is set up for following + this.navigation = new MobNavigation(this, world); + } + + public static DefaultAttributeContainer.Builder createMobAttributes() { + return TameableEntity.createMobAttributes() + .add(EntityAttributes.GENERIC_MAX_HEALTH, 20.0) + .add(EntityAttributes.GENERIC_MOVEMENT_SPEED, 0.3) + .add(EntityAttributes.GENERIC_FOLLOW_RANGE, 32.0); + } + + @Override + protected void initGoals() { + this.goalSelector.add(1, new FollowOwnerGoal(this, 1.0, 5.0f, 2.0f, false)); + this.goalSelector.add(2, new WanderAroundFarGoal(this, 0.8)); + this.goalSelector.add(3, new LookAtEntityGoal(this, PlayerEntity.class, 8.0f)); + this.goalSelector.add(4, new LookAroundGoal(this)); + } + + @Override + public boolean damage(DamageSource source, float amount) { + // Allow kill command through, block everything else + if (source == this.getDamageSources().outOfWorld()) return super.damage(source, amount); + return false; + } + + @Override + public void kill() { + super.kill(); + } + + @Override + public Text getName() { + return Text.translatable("entity.aicompanion2_0.ai_companion"); + } + + @Override + public Text getDisplayName() { + return Text.translatable("entity.aicompanion2_0.ai_companion"); + } + + @Override + public boolean isBreedingItem(ItemStack stack) { + return false; + } + + @Nullable + @Override + public PassiveEntity createChild(ServerWorld world, PassiveEntity entity) { + return null; + } + + // Bridge method fix + public EntityView method_48926() { + return (EntityView) this.getWorld(); + } +} \ No newline at end of file diff --git a/src/main/java/AiCompanion/aicompanion2_0/Aicompanion2_0.java b/src/main/java/AiCompanion/aicompanion2_0/Aicompanion2_0.java index 899d9e4..e3a0195 100644 --- a/src/main/java/AiCompanion/aicompanion2_0/Aicompanion2_0.java +++ b/src/main/java/AiCompanion/aicompanion2_0/Aicompanion2_0.java @@ -1,72 +1,136 @@ package AiCompanion.aicompanion2_0; -import net.fabricmc.api.ModInitializer; -import net.fabricmc.fabric.api.command.v1.CommandRegistrationCallback; -import net.minecraft.server.command.CommandManager; -import net.minecraft.text.Text; -import com.mojang.brigadier.arguments.StringArgumentType; - import java.io.BufferedReader; +import java.io.FileInputStream; +import java.io.IOException; import java.io.InputStreamReader; import java.io.OutputStream; import java.net.HttpURLConnection; import java.net.URL; +import java.nio.file.Path; +import java.nio.file.Files; +import java.util.Properties; +import com.mojang.brigadier.arguments.StringArgumentType; + +import net.fabricmc.api.ModInitializer; +import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; +import net.fabricmc.fabric.api.object.builder.v1.entity.FabricDefaultAttributeRegistry; +import net.fabricmc.fabric.api.object.builder.v1.entity.FabricEntityTypeBuilder; +import net.minecraft.entity.EntityDimensions; +import net.minecraft.entity.EntityType; +import net.minecraft.entity.SpawnGroup; +import net.minecraft.registry.Registries; +import net.minecraft.registry.Registry; +import net.minecraft.server.command.CommandManager; +import net.minecraft.text.Text; +import net.minecraft.util.Identifier; public class Aicompanion2_0 implements ModInitializer { public static final String MOD_ID = "aicompanion2_0"; - private static String API_BASE_URL = "http://localhost:11434"; - private static String API_PATH = "/api/generate"; + private static String API_BASE_URL = "https://ai.cametendo.org"; + private static String MODEL = "minecraft-helper"; + private static String API_KEY = ""; + private static String API_PATH = "/api/chat/completions"; + + public static final EntityType AI_COMPANION = Registry.register( + Registries.ENTITY_TYPE, + new Identifier(MOD_ID, "ai_companion"), + FabricEntityTypeBuilder.create(SpawnGroup.CREATURE, AIEntity::new) + .dimensions(EntityDimensions.fixed(0.6f, 1.8f)) + .build() + ); + + // Static getters used by the client side + public static String getApiBaseUrl() { return API_BASE_URL; } + public static String getModel() { return MODEL; } @Override public void onInitialize() { + loadConfig(); System.out.println("[" + MOD_ID + "] MOD STARTET!"); - CommandRegistrationCallback.EVENT.register((dispatcher, dedicated) -> { + FabricDefaultAttributeRegistry.register(AI_COMPANION, AIEntity.createMobAttributes()); + + CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) -> { System.out.println("[" + MOD_ID + "] Registriere /ai command"); dispatcher.register( - CommandManager.literal("ai") - .then(CommandManager.argument("frage", StringArgumentType.greedyString()) - .executes(ctx -> { - var player = ctx.getSource().getPlayer(); - String frage = StringArgumentType.getString(ctx, "frage"); + CommandManager.literal("ai") + // /ai spawn + .then(CommandManager.literal("spawn") + .executes(ctx -> { + var player = ctx.getSource().getPlayer(); + if (player != null) { + AIEntity companion = new AIEntity(AI_COMPANION, player.getWorld()); + companion.refreshPositionAndAngles(player.getX(), player.getY(), player.getZ(), 0, 0); + companion.setOwner(player); + companion.setTamed(true); + player.getWorld().spawnEntity(companion); + player.sendMessage(Text.literal("§6[AI] §fBegleiter wurde gerufen!"), false); + } + return 1; + }) + ) + // /ai kill + .then(CommandManager.literal("kill") + .executes(ctx -> { + var server = ctx.getSource().getServer(); + int removed = 0; + for (var world : server.getWorlds()) { + var companions = world.getEntitiesByType(AI_COMPANION, entity -> true); + for (var companion : companions) { + // Force-remove companion even when invulnerable. + companion.discard(); + removed++; + } + } + + final int removedCount = removed; + ctx.getSource().sendFeedback( + () -> Text.literal("§6[AI] §fBegleiter entfernt: " + removedCount), false); + return 1; + }) + ) + // /ai frage + .then(CommandManager.literal("frage") + .then(CommandManager.argument("frage", StringArgumentType.greedyString()) + .executes(ctx -> { + var player = ctx.getSource().getPlayer(); + var server = ctx.getSource().getServer(); + String frage = StringArgumentType.getString(ctx, "frage"); + + if (player != null) { + player.sendMessage( + Text.literal("§6[AI] §fDenke nach über: " + frage), false); + } + + new Thread(() -> { + try { + String antwort = callOllama(frage); + if (antwort == null || antwort.isEmpty()) return; + if (antwort.length() > 2000) antwort = antwort.substring(0, 2000) + "..."; if (player != null) { - player.sendMessage( - Text.literal("§6[AI] §fDenke nach über: " + frage), - false - ); + String finalAntwort = antwort; + server.execute(() -> player.sendMessage( + Text.literal("§6[AI] §fAntwort: " + finalAntwort), false)); } + } catch (Exception e) { + e.printStackTrace(); + if (player != null) { + String error = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName(); + server.execute(() -> player.sendMessage( + Text.literal("§c[AI] Fehler bei /ai frage: " + error), false)); + } + } + }).start(); - // KI‑Request in neuem Thread, damit der Server nicht hängt - new Thread(() -> { - try { - String antwort = callOllama(frage); - if (antwort == null || antwort.isEmpty()) { - return; - } - - // Antwort etwas kürzen, damit der Chat nicht explodiert - if (antwort.length() > 2000) { - antwort = antwort.substring(0, 2000) + "..."; - } - - if (player != null) { - player.sendMessage( - Text.literal("§6[AI] §fAntwort: " + antwort), - false - ); - } - } catch (Exception e) { - e.printStackTrace(); - } - }).start(); - - return 1; - }) - ) + return 1; + }) + ) + ) ); }); @@ -74,61 +138,134 @@ public class Aicompanion2_0 implements ModInitializer { } private String callOllama(String prompt) throws Exception { - // URL deines Ollama‑Servers, ggf. anpassen - URL url = new URL(API_BASE_URL + API_PATH); + String json = "{\"model\":\"" + jsonEscape(MODEL) + "\",\"messages\":[{\"role\":\"user\",\"content\":\"" + + jsonEscape(prompt) + "\"}],\"stream\":false}"; + HttpResult primary = postChatCompletion(API_PATH, json); + if (primary.status == 200) { + return extractAssistantContent(primary.body); + } + // Some deployments expose OpenAI-compatible chat under /v1/chat/completions. + if ((primary.status >= 500 || primary.status == 404) && !"/v1/chat/completions".equals(API_PATH)) { + HttpResult fallback = postChatCompletion("/v1/chat/completions", json); + if (fallback.status == 200) { + return extractAssistantContent(fallback.body); + } + return formatHttpError(fallback.status, fallback.body, "/v1/chat/completions"); + } + + return formatHttpError(primary.status, primary.body, API_PATH); + } + + private HttpResult postChatCompletion(String path, String json) throws Exception { + URL url = new URL(API_BASE_URL + path); HttpURLConnection conn = (HttpURLConnection) url.openConnection(); conn.setRequestMethod("POST"); conn.setRequestProperty("Content-Type", "application/json; charset=utf-8"); + if (API_KEY != null && !API_KEY.isBlank()) { + conn.setRequestProperty("Authorization", "Bearer " + API_KEY); + } conn.setDoOutput(true); - String json = """ - { - "model": "llama3", - "prompt": "%s" - } - """.formatted(prompt.replace("\"", "\\\"")); - try (OutputStream os = conn.getOutputStream()) { - byte[] input = json.getBytes("utf-8"); - os.write(input, 0, input.length); + os.write(json.getBytes("utf-8")); } int status = conn.getResponseCode(); - if (status != 200) { - System.out.println("[" + MOD_ID + "] Ollama HTTP Status: " + status); - return null; + String body = readResponseBody(conn, status >= 400); + conn.disconnect(); + return new HttpResult(status, body); + } + + private String extractAssistantContent(String body) { + int idx = body.indexOf("\"content\":\""); + if (idx >= 0) { + int start = idx + 11; + int end = body.indexOf("\"", start); + while (end > 0 && body.charAt(end - 1) == '\\') end = body.indexOf("\"", end + 1); + return body.substring(start, end).replace("\\n", "\n").replace("\\\"", "\""); } + return body; + } + + private String formatHttpError(int status, String body, String path) { + if (body == null || body.isBlank()) { + return "Fehler: HTTP " + status + " (" + path + ")"; + } + return "Fehler: HTTP " + status + " (" + path + ") - " + body; + } + + private String readResponseBody(HttpURLConnection conn, boolean error) throws IOException { + var stream = error ? conn.getErrorStream() : conn.getInputStream(); + if (stream == null) return ""; StringBuilder resp = new StringBuilder(); - try (BufferedReader br = new BufferedReader( - new InputStreamReader(conn.getInputStream(), "utf-8"))) { + try (BufferedReader br = new BufferedReader(new InputStreamReader(stream, "utf-8"))) { String line; - while ((line = br.readLine()) != null) { - resp.append(line); - } + while ((line = br.readLine()) != null) resp.append(line); } - - conn.disconnect(); - - // Sehr einfach: komplette JSON‑Antwort zurückgeben - // Später können wir das noch sauber parsen return resp.toString(); } -} -private void loadConfig() { - try { - Path configPath = Path.of("config", "aicompanion2_0.properties"); + + private String jsonEscape(String value) { + if (value == null) return ""; + String escaped = value.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + return escaped; + } + + private static class HttpResult { + private final int status; + private final String body; + + private HttpResult(int status, String body) { + this.status = status; + this.body = body; + } + } + + private void loadConfig() { + try { + Path configPath = Path.of("config", "aicompanion2_0.properties"); + Properties props = new Properties(); + try (FileInputStream in = new FileInputStream(configPath.toFile())) { + props.load(in); + } + API_BASE_URL = props.getProperty("api.baseUrl", API_BASE_URL); + API_PATH = props.getProperty("api.path", API_PATH); + MODEL = props.getProperty("api.model", MODEL); + API_KEY = props.getProperty("api.key", API_KEY); + + if (API_KEY == null || API_KEY.isBlank()) { + String clientFallbackKey = readApiKeyFrom(Path.of("config", "aicompanion2_0_client.properties")); + if (clientFallbackKey != null && !clientFallbackKey.isBlank()) { + API_KEY = clientFallbackKey; + } + } + } catch (IOException e) { + System.out.println("[" + MOD_ID + "] Keine Config gefunden, benutze Default-API."); + + try { + String clientFallbackKey = readApiKeyFrom(Path.of("config", "aicompanion2_0_client.properties")); + if (clientFallbackKey != null && !clientFallbackKey.isBlank()) { + API_KEY = clientFallbackKey; + } + } catch (IOException ignored) { + // Keep defaults when no config files are available. + } + } + } + + private String readApiKeyFrom(Path configPath) throws IOException { + if (!Files.exists(configPath)) return null; Properties props = new Properties(); try (FileInputStream in = new FileInputStream(configPath.toFile())) { props.load(in); } - API_BASE_URL = props.getProperty("api.baseUrl", API_BASE_URL); - API_PATH = props.getProperty("api.path", API_PATH); - } catch (IOException e) { - System.out.println("[" + MOD_ID + "] Keine Config gefunden, benutze Default-API."); + return props.getProperty("api.key", null); } } - - diff --git a/src/main/resources/aicompanion2_0.mixins.json b/src/main/resources/aicompanion2_0.mixins.json new file mode 100644 index 0000000..bf6d3eb --- /dev/null +++ b/src/main/resources/aicompanion2_0.mixins.json @@ -0,0 +1,10 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "AiCompanion.aicompanion2_0.mixin", + "compatibilityLevel": "JAVA_17", + "mixins": [], + "injectors": { + "defaultRequire": 1 + } +} \ No newline at end of file diff --git a/src/main/resources/assets/aicompanion2_0/lang/de_de.json b/src/main/resources/assets/aicompanion2_0/lang/de_de.json new file mode 100644 index 0000000..7e963c6 --- /dev/null +++ b/src/main/resources/assets/aicompanion2_0/lang/de_de.json @@ -0,0 +1 @@ +{"entity.aicompanion2_0.ai_companion": "KI-Begleiter"} diff --git a/src/main/resources/assets/aicompanion2_0/lang/en_us.json b/src/main/resources/assets/aicompanion2_0/lang/en_us.json new file mode 100644 index 0000000..945ac89 --- /dev/null +++ b/src/main/resources/assets/aicompanion2_0/lang/en_us.json @@ -0,0 +1 @@ +{"entity.aicompanion2_0.ai_companion": "AI-Companion"} diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json index f44600d..1f2ef0a 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -12,9 +12,14 @@ "entrypoints": { "main": [ "AiCompanion.aicompanion2_0.Aicompanion2_0" + ], + "client": [ + "AiCompanion.aicompanion2_0.client.AiCompanionClient" ] }, - "mixins": [], + "mixins": [ + "aicompanion2_0.mixins.json" + ], "depends": { "fabricloader": ">=0.14.0", "fabric-api": "*",