Fixed chat not working in multiplayer and added method to delete api key

This commit is contained in:
Cametendo
2026-03-18 09:04:41 +01:00
parent 012d20eaa7
commit f1171adf40
4 changed files with 219 additions and 49 deletions

View File

@@ -4,12 +4,14 @@ 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.networking.v1.ClientPlayNetworking;
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.text.Text;
import net.minecraft.util.ActionResult;
import net.minecraft.util.Identifier;
@@ -39,28 +41,89 @@ public class AiCompanionClient implements ClientModInitializer {
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));
}
});
});
// 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);
}
});
client.execute(() -> ensureApiKeyAndRun(client, () -> openChatScreen(client)));
return ActionResult.SUCCESS;
}
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();
}
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 void openChatScreen(MinecraftClient client) {
if (currentSession == null) {
currentSession = new AiChatSession(

View File

@@ -13,7 +13,7 @@ public class ApiKeyScreen extends Screen {
private final Runnable onSuccess;
public ApiKeyScreen(Runnable onSuccess) {
super(Text.literal("Enter API Key"));
super(Text.literal("Enter API Key (available on ai.cametendo.org)"));
this.onSuccess = onSuccess;
}

View File

@@ -11,7 +11,7 @@ public class ClientConfig {
private static String apiKey = null;
public static String getApiKey() {
if (apiKey == null) load();
load();
return apiKey;
}
@@ -20,8 +20,15 @@ public class ClientConfig {
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() {
if (apiKey == null) load();
load();
return apiKey != null && !apiKey.isBlank();
}
@@ -33,6 +40,7 @@ public class ClientConfig {
apiKey = loadApiKeyFrom(CONFIG_PATH);
}
} catch (IOException e) {
apiKey = null;
System.out.println("[aicompanion2_0] Could not load Client Config.");
}
}
@@ -68,4 +76,23 @@ public class ClientConfig {
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

@@ -15,6 +15,8 @@ import com.mojang.brigadier.arguments.StringArgumentType;
import net.fabricmc.api.ModInitializer;
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.FabricEntityTypeBuilder;
import net.minecraft.entity.EntityDimensions;
@@ -29,10 +31,17 @@ 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 = "https://ai.cametendo.org";
private static String MODEL = "minecraft-helper";
private static final String DEFAULT_API_BASE_URL = "https://ai.cametendo.org";
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_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");
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 EntityType<AIEntity> AI_COMPANION = Registry.register(
Registries.ENTITY_TYPE,
@@ -94,6 +103,39 @@ public class Aicompanion2_0 implements ModInitializer {
return 1;
})
)
// /ai delete-key
.then(CommandManager.literal("delete-key")
.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())
@@ -103,8 +145,16 @@ public class Aicompanion2_0 implements ModInitializer {
String frage = StringArgumentType.getString(ctx, "question");
if (player != null) {
player.sendMessage(
Text.literal("§6[AI] §fThink about: " + frage), false);
if (!ServerPlayNetworking.canSend(player, QUESTION_PACKET_ID)) {
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(() -> {
@@ -140,33 +190,40 @@ public class Aicompanion2_0 implements ModInitializer {
// Innerhalb deiner Klasse Aicompanion2_0
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}";
HttpResult primary = postChatCompletion(API_PATH, json);
HttpResult primary = postChatCompletion(apiBaseUrl, apiPath, apiKey, 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 ((primary.status >= 500 || primary.status == 404) && !"/v1/chat/completions".equals(apiPath)) {
HttpResult fallback = postChatCompletion(apiBaseUrl, "/v1/chat/completions", apiKey, 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);
return formatHttpError(primary.status, primary.body, apiPath);
}
private HttpResult postChatCompletion(String path, String json) throws Exception {
URL url = new URL(API_BASE_URL + path);
private HttpResult postChatCompletion(String apiBaseUrl, String path, String apiKey, 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 (API_KEY != null && !API_KEY.isBlank()) {
conn.setRequestProperty("Authorization", "Bearer " + API_KEY);
if (apiKey != null && !apiKey.isBlank()) {
conn.setRequestProperty("Authorization", "Bearer " + apiKey);
}
conn.setDoOutput(true);
@@ -230,36 +287,40 @@ 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 {
Path configPath = Path.of("config", "aicompanion2_0.properties");
if (Files.exists(SHARED_CONFIG_PATH)) {
Properties props = new Properties();
try (FileInputStream in = new FileInputStream(configPath.toFile())) {
try (FileInputStream in = new FileInputStream(SHARED_CONFIG_PATH.toFile())) {
props.load(in);
}
API_BASE_URL = props.getProperty("api.baseUrl", API_BASE_URL);
API_PATH = props.getProperty("api.path", API_PATH);
MODEL = props.getProperty("api.model", MODEL);
API_KEY = props.getProperty("api.key", API_KEY);
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();
}
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()) {
API_KEY = clientFallbackKey;
}
}
} 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 {
@@ -270,4 +331,23 @@ public class Aicompanion2_0 implements ModInitializer {
}
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;
}
}