Add entity and connection to open-webui

This commit is contained in:
Cametendo
2026-03-04 21:36:09 +01:00
parent 3ca8c8baca
commit 97734483fd
12 changed files with 791 additions and 80 deletions

0
gradlew vendored Normal file → Executable file
View File

View File

@@ -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;
}
}

View File

@@ -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;
}
}
}

View File

@@ -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));
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View 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();
}
}

View File

@@ -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<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
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")
// /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())
.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
);
Text.literal("§6[AI] §fDenke nach über: " + frage), false);
}
// KIRequest 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 (antwort == null || antwort.isEmpty()) return;
if (antwort.length() > 2000) antwort = antwort.substring(0, 2000) + "...";
if (player != null) {
player.sendMessage(
Text.literal("§6[AI] §fAntwort: " + antwort),
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();
return 1;
})
)
)
);
});
@@ -74,49 +138,96 @@ public class Aicompanion2_0 implements ModInitializer {
}
private String callOllama(String prompt) throws Exception {
// URL deines OllamaServers, 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 JSONAntwort zurückgeben
// Später können wir das noch sauber parsen
return resp.toString();
}
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");
@@ -126,9 +237,35 @@ private void loadConfig() {
}
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);
}
return props.getProperty("api.key", null);
}
}

View File

@@ -0,0 +1,10 @@
{
"required": true,
"minVersion": "0.8",
"package": "AiCompanion.aicompanion2_0.mixin",
"compatibilityLevel": "JAVA_17",
"mixins": [],
"injectors": {
"defaultRequire": 1
}
}

View File

@@ -0,0 +1 @@
{"entity.aicompanion2_0.ai_companion": "KI-Begleiter"}

View File

@@ -0,0 +1 @@
{"entity.aicompanion2_0.ai_companion": "AI-Companion"}

View File

@@ -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": "*",