8 Commits

Author SHA1 Message Date
Cametendo
9ec6786f6f Merge branch 'main' into safety-branch 2026-03-18 11:13:36 +01:00
Cametendo
63d6a32648 Added easter egg 2026-03-18 11:13:24 +01:00
Cametendo
8cc6fa53d9 Added easter egg 2026-03-18 11:11:14 +01:00
Cametendo
271d43e6e6 Revise API key instructions and correct typos
Updated API key entry instructions and fixed typos.
2026-03-18 09:22:25 +01:00
Cametendo
f1171adf40 Fixed chat not working in multiplayer and added method to delete api key 2026-03-18 09:04:41 +01:00
Adam237A
012d20eaa7 changed german t0 english 2026-03-11 09:08:42 +01:00
Cametendo
7774c68e8f Revise README for AI connection and commands
Updated information about AI requirements and commands.
2026-03-06 09:05:27 +01:00
Cametendo
05dd4ff24c change /ai frage <frage> to /ai question <question> 2026-03-06 08:34:21 +01:00
9 changed files with 422 additions and 77 deletions

View File

@@ -11,20 +11,43 @@ This Minecraft mod allows players, especially new ones, to get real-time assista
* **Game Version:** 1.21.1 * **Game Version:** 1.21.1
* **Loader:** [Fabric Loader](https://fabricmc.net/) * **Loader:** [Fabric Loader](https://fabricmc.net/)
* **Dependencies:** [Fabric API](https://www.curseforge.com/minecraft/mc-mods/fabric-api) * **Dependencies:** [Fabric API](https://www.curseforge.com/minecraft/mc-mods/fabric-api)
* **Information**: This mod uses a Cloud-AI. Active Internet connection is required. (No API Keys required) * **Information**: This mod uses a self-hosted-AI. Active Internet connection and API key required (no subscription).
* **API Key**: This Mod requires an API key from [ai.cametendo.org](https://ai.cametendo.org). Steps on how to acquire an API will be listed further down in the README.
## Usage ## Usage
- Command: `/ai <propmpt>`. With this command, you can ask the AI your question. There are three commands included in this mod:
- Use this command to talk to your AI Companion at any time during gameplay. - `/ai question`: This will let you communicate with the AI directly from the In-Game Chat
- `/ai spawn`: This will spawn an actual entity, your 'Companion' (multiple companions can be spawned). It will follow you around and can be interacted with by right-clicking.
- `/ai kill`: This will kill / remove all companions you spawned
- `/ai delete-key`: This lets you delete your API-Key, incase you deleted your account on the WebUI or experience other problems, you can use this command to enter a new key.
- Note: Depending on the question the AI will take 1 - 3 minutes to respond. Please be patient while your companion "thinks"! - Note: Depending on the question the AI will take 1 - 3 minutes to respond. Please be patient while your companion "thinks"!
## AI Companion Entity
The AI Companion doesn't just stand around, he too has his own features:
- `Chat-Window`: Right-clicking the Companion will open a chat window. In this, you will have a chat-interface that keeps your chat until you leave the world.
- `Follows you`: Instead of just standing around, the companion will ffollow you around wherever you go and walk around the world.
- `API-Key-Verification`: The first time you play this mod, you may need to use the companion to enter your API key.
## Installation ## Installation
1. If not already done, install [Fabric 1.21.1](https://maven.fabricmc.net/net/fabricmc/fabric-installer/1.1.1/fabric-installer-1.1.1.jar). (Click to download the Fabric Installer instantly) 1. If not already done, install [Fabric 1.21.1](https://maven.fabricmc.net/net/fabricmc/fabric-installer/1.1.1/fabric-installer-1.1.1.jar). (Click to download the Fabric Installer instantly)
2. Download the mod from the [Releases](https://github.com/Cametendo/minecraft-ai-companion-EMVs12-Project/releases) Tab. 2. Download the mod from the [Releases](https://github.com/Cametendo/minecraft-ai-companion-EMVs12-Project/releases) Tab.
3. Place the `.jar` file in your `%appdata%/.minecraft/mods` folder. 3. Place the `.jar` file in your `%appdata%/.minecraft/mods` folder.
4. Launch the game in your preferred launcher with the Fabric 1.21.1 Profile ([List of Minecraft Launchers](https://github.com/TayouVR/MinecraftLauncherComparison) 4. Launch the game in your preferred launcher with the Fabric 1.21.1 Profile ([List of Minecraft Launchers](https://github.com/TayouVR/MinecraftLauncherComparison)) <br>
**IMPORTANT**: This Installation Process is strictly for the offical version of Minecraft (including the official Launcher). We are not not responsible for any issues, data loss, or crashes that may occur when using third-party launchers or unofficial versions of the game. Support is only guaranteed for the official Fabric environment. **IMPORTANT**: This Installation Process is strictly for the offical version of Minecraft (including the official Launcher). We are not not responsible for any issues, data loss, or crashes that may occur when using third-party launchers or unofficial versions of the game. Support is only guaranteed for the official Fabric environment.
## API-Key
This mod requires an API key. To acquire an API key, follow these steps:
1. Create an account on [ai.cametendo.org](https://ai.cametendo.org)
2. Press your user profile (bottom-left corner) and switch to the tab "Account".
3. Find the option "API Keys" and press show. A very long line of dots should appear. This is your API key (hidden by default).
4. Copy it and start your game (or go back to it if it's already open)
5. Use /ai question and enter a question. A new window with "Enter your Open-WebUI API-Key" should appear. In the text box, enter your API Key and confirm it by pressing enter.
6. Alternatively, if that didn't work, use /ai spawn to spawn your AI Companion. THen right-click it and you should see the "Enter your Open-WebUI API-Key" window. Enter it into the text box and confirm it by pressing enter.
7. If everything worked, you can now send messages to your AI Companion. Have fun!
## License & Credits ## License & Credits
* **Authors:** [Cametendo](https://www.github.com/Cametendo), [ritonioz](https://www.github.com/ritonioz), [Adam237A](https://www.github.com/Adam237A) * **Authors:** [Cametendo](https://www.github.com/Cametendo), [ritonioz](https://www.github.com/ritonioz), [Adam237A](https://www.github.com/Adam237A)
* **License:** CC0 1.0 (Public Domain). Feel free to include this in any modpack! (Credits are appreciated but not required). * **License:** CC0 1.0 (Public Domain). Feel free to include this in any modpack! (Credits are appreciated but not required).

View File

@@ -29,18 +29,18 @@ public class AiChatScreen extends Screen {
protected void init() { protected void init() {
inputField = new TextFieldWidget( inputField = new TextFieldWidget(
textRenderer, width / 2 - 150, height - 35, 270, 20, textRenderer, width / 2 - 150, height - 35, 270, 20,
Text.literal("Nachricht...") Text.literal("Message...")
); );
inputField.setMaxLength(500); inputField.setMaxLength(500);
inputField.setFocused(true); inputField.setFocused(true);
addDrawableChild(inputField); addDrawableChild(inputField);
addDrawableChild(ButtonWidget.builder(Text.literal("Senden"), btn -> sendMessage()) addDrawableChild(ButtonWidget.builder(Text.literal("Send"), btn -> sendMessage())
.dimensions(width / 2 + 125, height - 35, 60, 20) .dimensions(width / 2 + 125, height - 35, 60, 20)
.build() .build()
); );
addDrawableChild(ButtonWidget.builder(Text.literal("Schließen"), btn -> close()) addDrawableChild(ButtonWidget.builder(Text.literal("Close"), btn -> close())
.dimensions(width / 2 - 30, height - 10, 60, 15) .dimensions(width / 2 - 30, height - 10, 60, 15)
.build() .build()
); );
@@ -61,9 +61,9 @@ public class AiChatScreen extends Screen {
inputField.setText(""); inputField.setText("");
displayLines().add("§eIch: §f" + msg); displayLines().add("§eIch: §f" + msg);
displayLines().add("§7[AI denkt nach...]"); displayLines().add("§7[AI thinking about...]");
session.sendMessage(msg, response -> { session.sendMessage(msg, response -> {
displayLines().remove("§7[AI denkt nach...]"); displayLines().remove("§7[AI thinking about...]");
displayLines().add("§6AI: §f" + response); displayLines().add("§6AI: §f" + response);
// auto-scroll to bottom // auto-scroll to bottom
scrollOffset = 0; scrollOffset = 0;

View File

@@ -21,7 +21,7 @@ public class AiChatSession {
this.apiBaseUrl = apiBaseUrl; this.apiBaseUrl = apiBaseUrl;
this.apiKey = apiKey; this.apiKey = apiKey;
this.model = model; this.model = model;
displayLines.add("§7Starte eine Unterhaltung mit deinem AI-Begleiter!"); displayLines.add("§7Start a conversation with your AI companion!");
} }
public List<String> getDisplayLines() { public List<String> getDisplayLines() {
@@ -31,13 +31,20 @@ public class AiChatSession {
public void sendMessage(String userMessage, Consumer<String> onResponse) { public void sendMessage(String userMessage, Consumer<String> onResponse) {
history.add(new String[]{"user", userMessage}); history.add(new String[]{"user", userMessage});
if (AiCompanionClient.tryTriggerArchEasterEgg(userMessage, response -> {
history.add(new String[]{"assistant", response});
onResponse.accept(response);
})) {
return;
}
new Thread(() -> { new Thread(() -> {
try { try {
String response = callOpenWebUI(); String response = callOpenWebUI();
history.add(new String[]{"assistant", response}); history.add(new String[]{"assistant", response});
onResponse.accept(response); onResponse.accept(response);
} catch (Exception e) { } catch (Exception e) {
onResponse.accept("§cFehler: " + e.getMessage()); onResponse.accept("§cError: " + e.getMessage());
} }
}).start(); }).start();
} }
@@ -118,9 +125,9 @@ public class AiChatSession {
private String formatHttpError(int status, String body, String path) { private String formatHttpError(int status, String body, String path) {
if (body == null || body.isBlank()) { if (body == null || body.isBlank()) {
return "Fehler: HTTP " + status + " (" + path + ")"; return "Error: HTTP " + status + " (" + path + ")";
} }
return "Fehler: HTTP " + status + " (" + path + ") - " + body; return "Error: HTTP " + status + " (" + path + ") - " + body;
} }
private String jsonEscape(String value) { private String jsonEscape(String value) {

View File

@@ -4,19 +4,29 @@ import AiCompanion.aicompanion2_0.AIEntity;
import AiCompanion.aicompanion2_0.Aicompanion2_0; import AiCompanion.aicompanion2_0.Aicompanion2_0;
import net.fabricmc.api.ClientModInitializer; import net.fabricmc.api.ClientModInitializer;
import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; 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; import net.fabricmc.fabric.api.client.rendering.v1.EntityRendererRegistry;
import net.fabricmc.fabric.api.event.player.UseEntityCallback; import net.fabricmc.fabric.api.event.player.UseEntityCallback;
import net.fabricmc.fabric.api.networking.v1.PacketByteBufs;
import net.minecraft.client.MinecraftClient; import net.minecraft.client.MinecraftClient;
import net.minecraft.client.render.entity.BipedEntityRenderer; import net.minecraft.client.render.entity.BipedEntityRenderer;
import net.minecraft.client.render.entity.model.EntityModelLayers; import net.minecraft.client.render.entity.model.EntityModelLayers;
import net.minecraft.client.render.entity.model.PlayerEntityModel; import net.minecraft.client.render.entity.model.PlayerEntityModel;
import net.minecraft.text.Text;
import net.minecraft.util.ActionResult; import net.minecraft.util.ActionResult;
import net.minecraft.util.Identifier; import net.minecraft.util.Identifier;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.function.Consumer;
public class AiCompanionClient implements ClientModInitializer { public class AiCompanionClient implements ClientModInitializer {
private static final Identifier DEFAULT_TEXTURE = new Identifier("aicompanion2_0", "textures/entity/skin.png");
private static final Identifier TUX_TEXTURE = new Identifier("aicompanion2_0", "textures/entity/tux-mc.png");
// One session per world load, shared across all right-clicks // One session per world load, shared across all right-clicks
private static AiChatSession currentSession = null; private static AiChatSession currentSession = null;
private static final Deque<Consumer<String>> pendingArchResponses = new ArrayDeque<>();
@Override @Override
public void onInitializeClient() { public void onInitializeClient() {
@@ -29,7 +39,7 @@ public class AiCompanionClient implements ClientModInitializer {
) { ) {
@Override @Override
public Identifier getTexture(AIEntity entity) { public Identifier getTexture(AIEntity entity) {
return new Identifier("aicompanion2_0", "textures/entity/skin.png"); return entity.isTuxMode() ? TUX_TEXTURE : DEFAULT_TEXTURE;
} }
} }
); );
@@ -39,28 +49,131 @@ public class AiCompanionClient implements ClientModInitializer {
currentSession = null; currentSession = null;
}); });
ClientPlayNetworking.registerGlobalReceiver(Aicompanion2_0.QUESTION_PACKET_ID, (client, handler, buf, responseSender) -> {
String question = buf.readString(32767);
client.execute(() -> ensureApiKeyAndRun(client, () -> askQuestion(client, question)));
});
ClientPlayNetworking.registerGlobalReceiver(Aicompanion2_0.DELETE_KEY_PACKET_ID, (client, handler, buf, responseSender) -> {
client.execute(() -> {
currentSession = null;
try {
boolean deleted = ClientConfig.deleteApiKey();
if (deleted) {
sendChatMessage(client, Text.literal("§6[AI] §fAPI key deleted."));
} else {
sendChatMessage(client, Text.literal("§6[AI] §fno api key deleted: none found"));
}
} catch (Exception e) {
String error = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName();
sendChatMessage(client, Text.literal("§c[AI] Could not delete API key: " + error));
}
});
});
ClientPlayNetworking.registerGlobalReceiver(Aicompanion2_0.ARCH_EASTER_EGG_RESPONSE_PACKET_ID, (client, handler, buf, responseSender) -> {
String response = buf.readString(32767);
client.execute(() -> finishArchEasterEgg(response));
});
// Open chat GUI on right-click // Open chat GUI on right-click
UseEntityCallback.EVENT.register((player, world, hand, entity, hitResult) -> { UseEntityCallback.EVENT.register((player, world, hand, entity, hitResult) -> {
if (world.isClient() && entity instanceof AIEntity) { if (world.isClient() && entity instanceof AIEntity) {
MinecraftClient client = MinecraftClient.getInstance(); MinecraftClient client = MinecraftClient.getInstance();
client.execute(() -> { client.execute(() -> ensureApiKeyAndRun(client, () -> openChatScreen(client)));
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.SUCCESS;
} }
return ActionResult.PASS; return ActionResult.PASS;
}); });
} }
private static void ensureApiKeyAndRun(MinecraftClient client, Runnable action) {
if (!ClientConfig.hasApiKey()) {
currentSession = null;
client.setScreen(new ApiKeyScreen(() -> {
currentSession = null;
action.run();
}));
return;
}
action.run();
}
public static boolean tryTriggerArchEasterEgg(String message, Consumer<String> onResponse) {
if (!isArchEasterEggTrigger(message)) {
return false;
}
if (!ClientPlayNetworking.canSend(Aicompanion2_0.ARCH_EASTER_EGG_PACKET_ID)) {
onResponse.accept("§cError: The server does not support the Arch easter egg.");
return true;
}
pendingArchResponses.addLast(onResponse);
try {
ClientPlayNetworking.send(Aicompanion2_0.ARCH_EASTER_EGG_PACKET_ID, PacketByteBufs.create());
} catch (RuntimeException e) {
pendingArchResponses.pollLast();
String error = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName();
onResponse.accept("§cError: " + error);
}
return true;
}
private static void askQuestion(MinecraftClient client, String question) {
sendChatMessage(client, Text.literal("§6[AI] §fThink about: " + question));
AiChatSession session = new AiChatSession(
Aicompanion2_0.getApiBaseUrl(),
ClientConfig.getApiKey(),
Aicompanion2_0.getModel()
);
session.sendMessage(question, response -> client.execute(() -> {
if (response == null || response.isEmpty()) {
return;
}
if (response.startsWith("§cError: ")) {
String error = response.substring("§cError: ".length());
sendChatMessage(client, Text.literal("§c[AI] Error with /ai question: " + error));
return;
}
String answer = response;
if (answer.length() > 2000) {
answer = answer.substring(0, 2000) + "...";
}
sendChatMessage(client, Text.literal("§6[AI] §fAnswer: " + answer));
}));
}
private static void sendChatMessage(MinecraftClient client, Text message) {
if (client.player != null) {
client.player.sendMessage(message, false);
}
}
private static boolean isArchEasterEggTrigger(String message) {
String normalized = message.trim().replaceAll("\\s+", " ").toLowerCase();
return "i use arch btw".equals(normalized)
|| "sudo".equals(normalized)
|| normalized.startsWith("sudo ");
}
private static void finishArchEasterEgg(String response) {
Consumer<String> callback = pendingArchResponses.pollFirst();
if (callback != null) {
callback.accept(response);
}
}
private static void openChatScreen(MinecraftClient client) { private static void openChatScreen(MinecraftClient client) {
if (currentSession == null) { if (currentSession == null) {
currentSession = new AiChatSession( currentSession = new AiChatSession(

View File

@@ -13,7 +13,7 @@ public class ApiKeyScreen extends Screen {
private final Runnable onSuccess; private final Runnable onSuccess;
public ApiKeyScreen(Runnable onSuccess) { public ApiKeyScreen(Runnable onSuccess) {
super(Text.literal("API Key eingeben")); super(Text.literal("Enter API Key (available on ai.cametendo.org)"));
this.onSuccess = onSuccess; this.onSuccess = onSuccess;
} }
@@ -27,7 +27,7 @@ public class ApiKeyScreen extends Screen {
keyField.setFocused(true); keyField.setFocused(true);
addDrawableChild(keyField); addDrawableChild(keyField);
addDrawableChild(ButtonWidget.builder(Text.literal("Bestätigen"), btn -> confirm()) addDrawableChild(ButtonWidget.builder(Text.literal("Confirm"), btn -> confirm())
.dimensions(width / 2 - 75, height / 2 + 20, 150, 20) .dimensions(width / 2 - 75, height / 2 + 20, 150, 20)
.build() .build()
); );
@@ -57,9 +57,9 @@ public class ApiKeyScreen extends Screen {
public void render(DrawContext context, int mouseX, int mouseY, float delta) { public void render(DrawContext context, int mouseX, int mouseY, float delta) {
renderBackground(context); renderBackground(context);
context.drawCenteredTextWithShadow(textRenderer, context.drawCenteredTextWithShadow(textRenderer,
"§6Bitte gib deinen Open-WebUI API Key ein:", width / 2, height / 2 - 30, 0xFFFFFF); "§6Please enter your Open WebUI API key:", width / 2, height / 2 - 30, 0xFFFFFF);
context.drawCenteredTextWithShadow(textRenderer, context.drawCenteredTextWithShadow(textRenderer,
"§7(Wird lokal gespeichert, nur einmalig nötig)", width / 2, height / 2 - 20, 0xAAAAAA); "§7(Stored locally, only required once.)", width / 2, height / 2 - 20, 0xAAAAAA);
super.render(context, mouseX, mouseY, delta); super.render(context, mouseX, mouseY, delta);
} }

View File

@@ -11,7 +11,7 @@ public class ClientConfig {
private static String apiKey = null; private static String apiKey = null;
public static String getApiKey() { public static String getApiKey() {
if (apiKey == null) load(); load();
return apiKey; return apiKey;
} }
@@ -20,8 +20,15 @@ public class ClientConfig {
save(); save();
} }
public static boolean deleteApiKey() throws IOException {
boolean deleted = deleteApiKeyFrom(SHARED_CONFIG_PATH, "AI Companion Shared Config");
deleted = deleteApiKeyFrom(CONFIG_PATH, "AI Companion Client Config") || deleted;
apiKey = null;
return deleted;
}
public static boolean hasApiKey() { public static boolean hasApiKey() {
if (apiKey == null) load(); load();
return apiKey != null && !apiKey.isBlank(); return apiKey != null && !apiKey.isBlank();
} }
@@ -33,7 +40,8 @@ public class ClientConfig {
apiKey = loadApiKeyFrom(CONFIG_PATH); apiKey = loadApiKeyFrom(CONFIG_PATH);
} }
} catch (IOException e) { } catch (IOException e) {
System.out.println("[aicompanion2_0] Konnte Client-Config nicht laden."); apiKey = null;
System.out.println("[aicompanion2_0] Could not load Client Config.");
} }
} }
@@ -42,7 +50,7 @@ public class ClientConfig {
saveApiKeyTo(CONFIG_PATH, "AI Companion Client Config"); saveApiKeyTo(CONFIG_PATH, "AI Companion Client Config");
saveApiKeyTo(SHARED_CONFIG_PATH, "AI Companion Shared Config"); saveApiKeyTo(SHARED_CONFIG_PATH, "AI Companion Shared Config");
} catch (IOException e) { } catch (IOException e) {
System.out.println("[aicompanion2_0] Konnte Client-Config nicht speichern."); System.out.println("[aicompanion2_0] Could not save Client Config.");
} }
} }
@@ -68,4 +76,23 @@ public class ClientConfig {
props.store(out, comment); props.store(out, comment);
} }
} }
private static boolean deleteApiKeyFrom(Path path, String comment) throws IOException {
if (!Files.exists(path)) return false;
Properties props = new Properties();
try (FileInputStream in = new FileInputStream(path.toFile())) {
props.load(in);
}
String existingKey = props.getProperty("api.key", null);
if (existingKey == null || existingKey.isBlank()) return false;
props.remove("api.key");
Files.createDirectories(path.getParent());
try (FileOutputStream out = new FileOutputStream(path.toFile())) {
props.store(out, comment);
}
return true;
}
} }

View File

@@ -5,6 +5,9 @@ import net.minecraft.entity.ai.goal.*;
import net.minecraft.entity.ai.pathing.MobNavigation; import net.minecraft.entity.ai.pathing.MobNavigation;
import net.minecraft.entity.attribute.DefaultAttributeContainer; import net.minecraft.entity.attribute.DefaultAttributeContainer;
import net.minecraft.entity.attribute.EntityAttributes; import net.minecraft.entity.attribute.EntityAttributes;
import net.minecraft.entity.data.DataTracker;
import net.minecraft.entity.data.TrackedData;
import net.minecraft.entity.data.TrackedDataHandlerRegistry;
import net.minecraft.entity.damage.DamageSource; import net.minecraft.entity.damage.DamageSource;
import net.minecraft.entity.passive.PassiveEntity; import net.minecraft.entity.passive.PassiveEntity;
import net.minecraft.entity.passive.TameableEntity; import net.minecraft.entity.passive.TameableEntity;
@@ -17,6 +20,9 @@ import net.minecraft.world.World;
import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Nullable;
public class AIEntity extends TameableEntity { public class AIEntity extends TameableEntity {
private static final TrackedData<Boolean> TUX_MODE =
DataTracker.registerData(AIEntity.class, TrackedDataHandlerRegistry.BOOLEAN);
private int tuxTicksRemaining = 0;
public AIEntity(EntityType<? extends TameableEntity> entityType, World world) { public AIEntity(EntityType<? extends TameableEntity> entityType, World world) {
super(entityType, world); super(entityType, world);
@@ -34,6 +40,12 @@ public class AIEntity extends TameableEntity {
.add(EntityAttributes.GENERIC_FOLLOW_RANGE, 32.0); .add(EntityAttributes.GENERIC_FOLLOW_RANGE, 32.0);
} }
@Override
protected void initDataTracker() {
super.initDataTracker();
this.dataTracker.startTracking(TUX_MODE, false);
}
@Override @Override
protected void initGoals() { protected void initGoals() {
this.goalSelector.add(1, new FollowOwnerGoal(this, 1.0, 5.0f, 2.0f, false)); this.goalSelector.add(1, new FollowOwnerGoal(this, 1.0, 5.0f, 2.0f, false));
@@ -42,6 +54,19 @@ public class AIEntity extends TameableEntity {
this.goalSelector.add(4, new LookAroundGoal(this)); this.goalSelector.add(4, new LookAroundGoal(this));
} }
@Override
public void tick() {
super.tick();
if (!this.getWorld().isClient() && tuxTicksRemaining > 0) {
tuxTicksRemaining--;
if (tuxTicksRemaining == 0) {
this.dataTracker.set(TUX_MODE, false);
}
}
}
@Override @Override
public boolean damage(DamageSource source, float amount) { public boolean damage(DamageSource source, float amount) {
// Allow kill command through, block everything else // Allow kill command through, block everything else
@@ -75,8 +100,17 @@ public class AIEntity extends TameableEntity {
return null; return null;
} }
public void activateTuxMode(int durationTicks) {
tuxTicksRemaining = Math.max(tuxTicksRemaining, durationTicks);
this.dataTracker.set(TUX_MODE, true);
}
public boolean isTuxMode() {
return this.dataTracker.get(TUX_MODE);
}
// Bridge method fix // Bridge method fix
public EntityView method_48926() { public EntityView method_48926() {
return (EntityView) this.getWorld(); return (EntityView) this.getWorld();
} }
} }

View File

@@ -9,30 +9,52 @@ import java.net.HttpURLConnection;
import java.net.URL; import java.net.URL;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Files; import java.nio.file.Files;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties; import java.util.Properties;
import java.util.UUID;
import com.mojang.brigadier.arguments.StringArgumentType; import com.mojang.brigadier.arguments.StringArgumentType;
import net.fabricmc.api.ModInitializer; import net.fabricmc.api.ModInitializer;
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
import net.fabricmc.fabric.api.networking.v1.PacketByteBufs;
import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking;
import net.fabricmc.fabric.api.object.builder.v1.entity.FabricDefaultAttributeRegistry; import net.fabricmc.fabric.api.object.builder.v1.entity.FabricDefaultAttributeRegistry;
import net.fabricmc.fabric.api.object.builder.v1.entity.FabricEntityTypeBuilder; import net.fabricmc.fabric.api.object.builder.v1.entity.FabricEntityTypeBuilder;
import net.minecraft.entity.EntityDimensions; import net.minecraft.entity.EntityDimensions;
import net.minecraft.entity.EntityType; import net.minecraft.entity.EntityType;
import net.minecraft.entity.SpawnGroup; import net.minecraft.entity.SpawnGroup;
import net.minecraft.entity.effect.StatusEffectInstance;
import net.minecraft.entity.effect.StatusEffects;
import net.minecraft.registry.Registries; import net.minecraft.registry.Registries;
import net.minecraft.registry.Registry; import net.minecraft.registry.Registry;
import net.minecraft.server.command.CommandManager; import net.minecraft.server.command.CommandManager;
import net.minecraft.server.network.ServerPlayerEntity;
import net.minecraft.server.world.ServerWorld;
import net.minecraft.text.Text; import net.minecraft.text.Text;
import net.minecraft.util.Identifier; import net.minecraft.util.Identifier;
public class Aicompanion2_0 implements ModInitializer { public class Aicompanion2_0 implements ModInitializer {
public static final String MOD_ID = "aicompanion2_0"; public static final String MOD_ID = "aicompanion2_0";
private static String API_BASE_URL = "https://ai.cametendo.org"; private static final String DEFAULT_API_BASE_URL = "https://ai.cametendo.org";
private static String MODEL = "minecraft-helper"; private static final String DEFAULT_MODEL = "minecraft-helper";
private static final String DEFAULT_API_PATH = "/api/chat/completions";
private static String API_BASE_URL = DEFAULT_API_BASE_URL;
private static String MODEL = DEFAULT_MODEL;
private static String API_KEY = ""; private static String API_KEY = "";
private static String API_PATH = "/api/chat/completions"; private static String API_PATH = DEFAULT_API_PATH;
private static final Path SHARED_CONFIG_PATH = Path.of("config", "aicompanion2_0.properties");
private static final Path CLIENT_CONFIG_PATH = Path.of("config", "aicompanion2_0_client.properties");
private static final long ARCH_EASTER_EGG_COOLDOWN_MS = 15L * 60L * 1000L;
private static final int ARCH_EASTER_EGG_BUFF_DURATION_TICKS = 20 * 60;
private static final int ARCH_EASTER_EGG_TUX_DURATION_TICKS = 20 * 10;
private static final Map<UUID, Long> ARCH_EASTER_EGG_LAST_USED = new HashMap<>();
public static final Identifier QUESTION_PACKET_ID = new Identifier(MOD_ID, "question");
public static final Identifier DELETE_KEY_PACKET_ID = new Identifier(MOD_ID, "delete_key");
public static final Identifier ARCH_EASTER_EGG_PACKET_ID = new Identifier(MOD_ID, "arch_easter_egg");
public static final Identifier ARCH_EASTER_EGG_RESPONSE_PACKET_ID = new Identifier(MOD_ID, "arch_easter_egg_response");
public static final EntityType<AIEntity> AI_COMPANION = Registry.register( public static final EntityType<AIEntity> AI_COMPANION = Registry.register(
Registries.ENTITY_TYPE, Registries.ENTITY_TYPE,
@@ -52,6 +74,9 @@ public class Aicompanion2_0 implements ModInitializer {
System.out.println("[" + MOD_ID + "] MOD STARTET!"); System.out.println("[" + MOD_ID + "] MOD STARTET!");
FabricDefaultAttributeRegistry.register(AI_COMPANION, AIEntity.createMobAttributes()); FabricDefaultAttributeRegistry.register(AI_COMPANION, AIEntity.createMobAttributes());
ServerPlayNetworking.registerGlobalReceiver(ARCH_EASTER_EGG_PACKET_ID, (server, player, handler, buf, responseSender) ->
server.execute(() -> sendArchEasterEggResponse(player))
);
CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) -> { CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) -> {
System.out.println("[" + MOD_ID + "] Registriere /ai command"); System.out.println("[" + MOD_ID + "] Registriere /ai command");
@@ -68,7 +93,7 @@ public class Aicompanion2_0 implements ModInitializer {
companion.setOwner(player); companion.setOwner(player);
companion.setTamed(true); companion.setTamed(true);
player.getWorld().spawnEntity(companion); player.getWorld().spawnEntity(companion);
player.sendMessage(Text.literal("§6[AI] §fBegleiter wurde gerufen!"), false); player.sendMessage(Text.literal("§6[AI] §fThe companion has been called!"), false);
} }
return 1; return 1;
}) })
@@ -90,21 +115,62 @@ public class Aicompanion2_0 implements ModInitializer {
final int removedCount = removed; final int removedCount = removed;
ctx.getSource().sendFeedback( ctx.getSource().sendFeedback(
() -> Text.literal("§6[AI] §fBegleiter entfernt: " + removedCount), false); () -> Text.literal("§6[AI] §fCompanion removed: " + removedCount), false);
return 1; return 1;
}) })
) )
// /ai frage <frage> // /ai delete-key
.then(CommandManager.literal("frage") .then(CommandManager.literal("delete-key")
.then(CommandManager.argument("frage", StringArgumentType.greedyString()) .executes(ctx -> {
var player = ctx.getSource().getPlayer();
if (player != null) {
if (!ServerPlayNetworking.canSend(player, DELETE_KEY_PACKET_ID)) {
ctx.getSource().sendFeedback(
() -> Text.literal("§c[AI] delete-key requires the AI Companion client mod."), false);
return 0;
}
ServerPlayNetworking.send(player, DELETE_KEY_PACKET_ID, PacketByteBufs.create());
return 1;
}
try {
boolean deleted = deleteStoredApiKey();
if (deleted) {
ctx.getSource().sendFeedback(
() -> Text.literal("§6[AI] §fAPI key deleted."), false);
} else {
ctx.getSource().sendFeedback(
() -> Text.literal("§6[AI] §fno api key deleted: none found"), false);
}
return 1;
} catch (IOException e) {
String error = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName();
ctx.getSource().sendFeedback(
() -> Text.literal("§c[AI] Could not delete API key: " + error), false);
return 0;
}
})
)
// /ai question <question>
.then(CommandManager.literal("question")
.then(CommandManager.argument("question", StringArgumentType.greedyString())
.executes(ctx -> { .executes(ctx -> {
var player = ctx.getSource().getPlayer(); var player = ctx.getSource().getPlayer();
var server = ctx.getSource().getServer(); var server = ctx.getSource().getServer();
String frage = StringArgumentType.getString(ctx, "frage"); String frage = StringArgumentType.getString(ctx, "question");
if (player != null) { if (player != null) {
player.sendMessage( if (!ServerPlayNetworking.canSend(player, QUESTION_PACKET_ID)) {
Text.literal("§6[AI] §fDenke nach über: " + frage), false); ctx.getSource().sendFeedback(
() -> Text.literal("§c[AI] question requires the AI Companion client mod."), false);
return 0;
}
var buf = PacketByteBufs.create();
buf.writeString(frage);
ServerPlayNetworking.send(player, QUESTION_PACKET_ID, buf);
return 1;
} }
new Thread(() -> { new Thread(() -> {
@@ -115,14 +181,14 @@ public class Aicompanion2_0 implements ModInitializer {
if (player != null) { if (player != null) {
String finalAntwort = antwort; String finalAntwort = antwort;
server.execute(() -> player.sendMessage( server.execute(() -> player.sendMessage(
Text.literal("§6[AI] §fAntwort: " + finalAntwort), false)); Text.literal("§6[AI] §fAnswer: " + finalAntwort), false));
} }
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace(); e.printStackTrace();
if (player != null) { if (player != null) {
String error = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName(); String error = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName();
server.execute(() -> player.sendMessage( server.execute(() -> player.sendMessage(
Text.literal("§c[AI] Fehler bei /ai frage: " + error), false)); Text.literal("§c[AI] Error with /ai question: " + error), false));
} }
} }
}).start(); }).start();
@@ -140,33 +206,40 @@ public class Aicompanion2_0 implements ModInitializer {
// Innerhalb deiner Klasse Aicompanion2_0 // Innerhalb deiner Klasse Aicompanion2_0
private String callOllama(String prompt) throws Exception { private String callOllama(String prompt) throws Exception {
String json = "{\"model\":\"" + jsonEscape(MODEL) + "\",\"messages\":[{\"role\":\"user\",\"content\":\"" loadConfig();
String apiBaseUrl = API_BASE_URL;
String apiPath = API_PATH;
String model = MODEL;
String apiKey = API_KEY;
String json = "{\"model\":\"" + jsonEscape(model) + "\",\"messages\":[{\"role\":\"user\",\"content\":\""
+ jsonEscape(prompt) + "\"}],\"stream\":false}"; + jsonEscape(prompt) + "\"}],\"stream\":false}";
HttpResult primary = postChatCompletion(API_PATH, json); HttpResult primary = postChatCompletion(apiBaseUrl, apiPath, apiKey, json);
if (primary.status == 200) { if (primary.status == 200) {
return extractAssistantContent(primary.body); return extractAssistantContent(primary.body);
} }
// Some deployments expose OpenAI-compatible chat under /v1/chat/completions. // Some deployments expose OpenAI-compatible chat under /v1/chat/completions.
if ((primary.status >= 500 || primary.status == 404) && !"/v1/chat/completions".equals(API_PATH)) { if ((primary.status >= 500 || primary.status == 404) && !"/v1/chat/completions".equals(apiPath)) {
HttpResult fallback = postChatCompletion("/v1/chat/completions", json); HttpResult fallback = postChatCompletion(apiBaseUrl, "/v1/chat/completions", apiKey, json);
if (fallback.status == 200) { if (fallback.status == 200) {
return extractAssistantContent(fallback.body); return extractAssistantContent(fallback.body);
} }
return formatHttpError(fallback.status, fallback.body, "/v1/chat/completions"); return formatHttpError(fallback.status, fallback.body, "/v1/chat/completions");
} }
return formatHttpError(primary.status, primary.body, API_PATH); return formatHttpError(primary.status, primary.body, apiPath);
} }
private HttpResult postChatCompletion(String path, String json) throws Exception { private HttpResult postChatCompletion(String apiBaseUrl, String path, String apiKey, String json) throws Exception {
URL url = new URL(API_BASE_URL + path); URL url = new URL(apiBaseUrl + path);
HttpURLConnection conn = (HttpURLConnection) url.openConnection(); HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST"); conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Type", "application/json; charset=utf-8"); conn.setRequestProperty("Content-Type", "application/json; charset=utf-8");
if (API_KEY != null && !API_KEY.isBlank()) { if (apiKey != null && !apiKey.isBlank()) {
conn.setRequestProperty("Authorization", "Bearer " + API_KEY); conn.setRequestProperty("Authorization", "Bearer " + apiKey);
} }
conn.setDoOutput(true); conn.setDoOutput(true);
@@ -230,38 +303,42 @@ public class Aicompanion2_0 implements ModInitializer {
} }
} }
private void loadConfig() { private synchronized void loadConfig() {
API_BASE_URL = DEFAULT_API_BASE_URL;
API_PATH = DEFAULT_API_PATH;
MODEL = DEFAULT_MODEL;
API_KEY = "";
try { try {
Path configPath = Path.of("config", "aicompanion2_0.properties"); if (Files.exists(SHARED_CONFIG_PATH)) {
Properties props = new Properties(); Properties props = new Properties();
try (FileInputStream in = new FileInputStream(configPath.toFile())) { try (FileInputStream in = new FileInputStream(SHARED_CONFIG_PATH.toFile())) {
props.load(in); props.load(in);
}
API_BASE_URL = props.getProperty("api.baseUrl", DEFAULT_API_BASE_URL);
API_PATH = props.getProperty("api.path", DEFAULT_API_PATH);
MODEL = props.getProperty("api.model", DEFAULT_MODEL);
API_KEY = props.getProperty("api.key", "").trim();
} }
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()) { if (API_KEY == null || API_KEY.isBlank()) {
String clientFallbackKey = readApiKeyFrom(Path.of("config", "aicompanion2_0_client.properties")); String clientFallbackKey = readApiKeyFrom(CLIENT_CONFIG_PATH);
if (clientFallbackKey != null && !clientFallbackKey.isBlank()) { if (clientFallbackKey != null && !clientFallbackKey.isBlank()) {
API_KEY = clientFallbackKey; API_KEY = clientFallbackKey;
} }
} }
} catch (IOException e) { } catch (IOException e) {
System.out.println("[" + MOD_ID + "] Keine Config gefunden, benutze Default-API."); System.out.println("[" + MOD_ID + "] Could not reload config, using defaults.");
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 boolean deleteStoredApiKey() throws IOException {
boolean deleted = deleteApiKeyFrom(SHARED_CONFIG_PATH, "AI Companion Shared Config");
deleted = deleteApiKeyFrom(CLIENT_CONFIG_PATH, "AI Companion Client Config") || deleted;
API_KEY = "";
return deleted;
}
private String readApiKeyFrom(Path configPath) throws IOException { private String readApiKeyFrom(Path configPath) throws IOException {
if (!Files.exists(configPath)) return null; if (!Files.exists(configPath)) return null;
Properties props = new Properties(); Properties props = new Properties();
@@ -270,4 +347,68 @@ public class Aicompanion2_0 implements ModInitializer {
} }
return props.getProperty("api.key", null); return props.getProperty("api.key", null);
} }
}
private boolean deleteApiKeyFrom(Path configPath, String comment) throws IOException {
if (!Files.exists(configPath)) return false;
Properties props = new Properties();
try (FileInputStream in = new FileInputStream(configPath.toFile())) {
props.load(in);
}
String existingKey = props.getProperty("api.key", null);
if (existingKey == null || existingKey.isBlank()) return false;
props.remove("api.key");
Files.createDirectories(configPath.getParent());
try (OutputStream out = Files.newOutputStream(configPath)) {
props.store(out, comment);
}
return true;
}
private void sendArchEasterEggResponse(ServerPlayerEntity player) {
if (!ServerPlayNetworking.canSend(player, ARCH_EASTER_EGG_RESPONSE_PACKET_ID)) {
return;
}
String response = triggerArchEasterEgg(player);
var buf = PacketByteBufs.create();
buf.writeString(response);
ServerPlayNetworking.send(player, ARCH_EASTER_EGG_RESPONSE_PACKET_ID, buf);
}
private String triggerArchEasterEgg(ServerPlayerEntity player) {
long now = System.currentTimeMillis();
Long lastUsed = ARCH_EASTER_EGG_LAST_USED.get(player.getUuid());
if (lastUsed != null) {
long remainingMs = ARCH_EASTER_EGG_COOLDOWN_MS - (now - lastUsed);
if (remainingMs > 0) {
return "Optimization already complete. Cooldown remaining: " + formatCooldown(remainingMs) + ".";
}
}
ARCH_EASTER_EGG_LAST_USED.put(player.getUuid(), now);
player.addStatusEffect(new StatusEffectInstance(StatusEffects.SPEED, ARCH_EASTER_EGG_BUFF_DURATION_TICKS, 1));
player.addStatusEffect(new StatusEffectInstance(StatusEffects.HASTE, ARCH_EASTER_EGG_BUFF_DURATION_TICKS, 1));
activateTuxModeForOwnedCompanions(player);
return "Optimization complete. Bloatware removed.";
}
private void activateTuxModeForOwnedCompanions(ServerPlayerEntity player) {
for (ServerWorld world : player.getServer().getWorlds()) {
for (AIEntity companion : world.getEntitiesByType(AI_COMPANION, entity ->
player.getUuid().equals(entity.getOwnerUuid()))) {
companion.activateTuxMode(ARCH_EASTER_EGG_TUX_DURATION_TICKS);
}
}
}
private String formatCooldown(long remainingMs) {
long totalSeconds = Math.max(1, (remainingMs + 999) / 1000);
long minutes = totalSeconds / 60;
long seconds = totalSeconds % 60;
return minutes + "m " + seconds + "s";
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB