mirror of
https://github.com/ritonioz/minecraft-ai-companion-mod-EMVs12-Project.git
synced 2026-03-18 07:10:20 +01:00
Compare commits
2 Commits
ef2567e2ee
...
beta-v1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9543cc38c3 | ||
|
|
97734483fd |
@@ -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<String> 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<OrderedText> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String[]> history = new ArrayList<>(); // [role, content]
|
||||||
|
private final List<String> 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<String> getDisplayLines() {
|
||||||
|
return displayLines;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void sendMessage(String userMessage, Consumer<String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<AIEntity, PlayerEntityModel<AIEntity>>(
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
82
src/main/java/AiCompanion/aicompanion2_0/AIEntity.java
Normal file
82
src/main/java/AiCompanion/aicompanion2_0/AIEntity.java
Normal file
@@ -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<? extends TameableEntity> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,77 +1,136 @@
|
|||||||
package AiCompanion.aicompanion2_0;
|
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.BufferedReader;
|
||||||
|
import java.io.FileInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
import java.io.InputStreamReader;
|
import java.io.InputStreamReader;
|
||||||
import java.io.OutputStream;
|
import java.io.OutputStream;
|
||||||
import java.net.HttpURLConnection;
|
import java.net.HttpURLConnection;
|
||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
|
|
||||||
import java.io.FileInputStream;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Files;
|
||||||
import java.util.Properties;
|
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 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 = "http://ollama.cametendo.org";
|
private static String API_BASE_URL = "https://ai.cametendo.org";
|
||||||
private static String API_PATH = "/api/generate";
|
private static String MODEL = "minecraft-helper";
|
||||||
|
private static String API_KEY = "";
|
||||||
|
private static String API_PATH = "/api/chat/completions";
|
||||||
|
|
||||||
|
public static final EntityType<AIEntity> 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
|
@Override
|
||||||
public void onInitialize() {
|
public void onInitialize() {
|
||||||
|
loadConfig();
|
||||||
System.out.println("[" + MOD_ID + "] MOD STARTET!");
|
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");
|
System.out.println("[" + MOD_ID + "] Registriere /ai command");
|
||||||
|
|
||||||
dispatcher.register(
|
dispatcher.register(
|
||||||
CommandManager.literal("ai")
|
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 <frage>
|
||||||
|
.then(CommandManager.literal("frage")
|
||||||
.then(CommandManager.argument("frage", StringArgumentType.greedyString())
|
.then(CommandManager.argument("frage", StringArgumentType.greedyString())
|
||||||
.executes(ctx -> {
|
.executes(ctx -> {
|
||||||
var player = ctx.getSource().getPlayer();
|
var player = ctx.getSource().getPlayer();
|
||||||
|
var server = ctx.getSource().getServer();
|
||||||
String frage = StringArgumentType.getString(ctx, "frage");
|
String frage = StringArgumentType.getString(ctx, "frage");
|
||||||
|
|
||||||
if (player != null) {
|
if (player != null) {
|
||||||
player.sendMessage(
|
player.sendMessage(
|
||||||
Text.literal("§6[AI] §fDenke nach über: " + frage),
|
Text.literal("§6[AI] §fDenke nach über: " + frage), false);
|
||||||
false
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// KI‑Request in neuem Thread, damit der Server nicht hängt
|
|
||||||
new Thread(() -> {
|
new Thread(() -> {
|
||||||
try {
|
try {
|
||||||
String antwort = callOllama(frage);
|
String antwort = callOllama(frage);
|
||||||
if (antwort == null || antwort.isEmpty()) {
|
if (antwort == null || antwort.isEmpty()) return;
|
||||||
return;
|
if (antwort.length() > 2000) antwort = antwort.substring(0, 2000) + "...";
|
||||||
}
|
|
||||||
|
|
||||||
// Antwort etwas kürzen, damit der Chat nicht explodiert
|
|
||||||
if (antwort.length() > 2000) {
|
|
||||||
antwort = antwort.substring(0, 2000) + "...";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (player != null) {
|
if (player != null) {
|
||||||
player.sendMessage(
|
String finalAntwort = antwort;
|
||||||
Text.literal("§6[AI] §fAntwort: " + antwort),
|
server.execute(() -> player.sendMessage(
|
||||||
false
|
Text.literal("§6[AI] §fAntwort: " + finalAntwort), false));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
e.printStackTrace();
|
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();
|
}).start();
|
||||||
|
|
||||||
return 1;
|
return 1;
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,46 +140,94 @@ 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 {
|
||||||
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();
|
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()) {
|
||||||
|
conn.setRequestProperty("Authorization", "Bearer " + API_KEY);
|
||||||
|
}
|
||||||
conn.setDoOutput(true);
|
conn.setDoOutput(true);
|
||||||
|
|
||||||
// WICHTIG: "stream": false hinzugefügt
|
|
||||||
String json = """
|
|
||||||
{
|
|
||||||
"model": "gpt-oss:20b",
|
|
||||||
"prompt": "%s",
|
|
||||||
"stream": false
|
|
||||||
}
|
|
||||||
""".formatted(prompt.replace("\"", "\\\""));
|
|
||||||
|
|
||||||
try (OutputStream os = conn.getOutputStream()) {
|
try (OutputStream os = conn.getOutputStream()) {
|
||||||
byte[] input = json.getBytes("utf-8");
|
os.write(json.getBytes("utf-8"));
|
||||||
os.write(input, 0, input.length);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (conn.getResponseCode() != 200) return "Fehler: " + conn.getResponseCode();
|
int status = conn.getResponseCode();
|
||||||
|
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();
|
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;
|
String line;
|
||||||
while ((line = br.readLine()) != null) {
|
while ((line = br.readLine()) != null) resp.append(line);
|
||||||
resp.append(line);
|
|
||||||
}
|
}
|
||||||
}
|
return resp.toString();
|
||||||
conn.disconnect();
|
|
||||||
|
|
||||||
// Primitives Parsing der Antwort (ohne externe Library)
|
|
||||||
String fullResponse = resp.toString();
|
|
||||||
if (fullResponse.contains("\"response\":\"")) {
|
|
||||||
int start = fullResponse.indexOf("\"response\":\"") + 12;
|
|
||||||
int end = fullResponse.indexOf("\"", start);
|
|
||||||
return fullResponse.substring(start, end);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return fullResponse;
|
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() {
|
private void loadConfig() {
|
||||||
@@ -132,8 +239,35 @@ private void loadConfig() {
|
|||||||
}
|
}
|
||||||
API_BASE_URL = props.getProperty("api.baseUrl", API_BASE_URL);
|
API_BASE_URL = props.getProperty("api.baseUrl", API_BASE_URL);
|
||||||
API_PATH = props.getProperty("api.path", API_PATH);
|
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) {
|
} catch (IOException e) {
|
||||||
System.out.println("[" + MOD_ID + "] Keine Config gefunden, benutze Default-API.");
|
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);
|
||||||
|
}
|
||||||
|
return props.getProperty("api.key", null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
10
src/main/resources/aicompanion2_0.mixins.json
Normal file
10
src/main/resources/aicompanion2_0.mixins.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"required": true,
|
||||||
|
"minVersion": "0.8",
|
||||||
|
"package": "AiCompanion.aicompanion2_0.mixin",
|
||||||
|
"compatibilityLevel": "JAVA_17",
|
||||||
|
"mixins": [],
|
||||||
|
"injectors": {
|
||||||
|
"defaultRequire": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/main/resources/assets/aicompanion2_0/lang/de_de.json
Normal file
1
src/main/resources/assets/aicompanion2_0/lang/de_de.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"entity.aicompanion2_0.ai_companion": "KI-Begleiter"}
|
||||||
1
src/main/resources/assets/aicompanion2_0/lang/en_us.json
Normal file
1
src/main/resources/assets/aicompanion2_0/lang/en_us.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"entity.aicompanion2_0.ai_companion": "AI-Companion"}
|
||||||
@@ -12,9 +12,14 @@
|
|||||||
"entrypoints": {
|
"entrypoints": {
|
||||||
"main": [
|
"main": [
|
||||||
"AiCompanion.aicompanion2_0.Aicompanion2_0"
|
"AiCompanion.aicompanion2_0.Aicompanion2_0"
|
||||||
|
],
|
||||||
|
"client": [
|
||||||
|
"AiCompanion.aicompanion2_0.client.AiCompanionClient"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"mixins": [],
|
"mixins": [
|
||||||
|
"aicompanion2_0.mixins.json"
|
||||||
|
],
|
||||||
"depends": {
|
"depends": {
|
||||||
"fabricloader": ">=0.14.0",
|
"fabricloader": ">=0.14.0",
|
||||||
"fabric-api": "*",
|
"fabric-api": "*",
|
||||||
|
|||||||
Reference in New Issue
Block a user