mirror of
https://github.com/ritonioz/minecraft-ai-companion-mod-EMVs12-Project.git
synced 2026-06-20 20:25:01 +02:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14d8b0c431 | ||
|
|
90ba17c108 |
134
README.md
134
README.md
@@ -4,52 +4,118 @@
|
||||
---
|
||||
|
||||
## About
|
||||
This Minecraft mod allows players, especially new ones, to get real-time assistance. If you are confused about a mechanic or a location, simply ask your "Companion" for advice.
|
||||
- Example: If a player is in the Deep Dark, they can ask: "What can I do here?" and the AI will provide useful tips and warnings about the Warden.
|
||||
Minecraft AI Companion adds an AI-powered helper to Minecraft. You can ask questions directly in chat, or spawn a companion entity that follows you and opens its own chat window when you right-click it.
|
||||
|
||||
The mod is especially useful for newer players, but it also works as a general in-game assistant depending on the mode you choose.
|
||||
|
||||
Examples:
|
||||
- "What can I do in the Deep Dark?"
|
||||
- "How do I find Netherite?"
|
||||
- "What does this structure do?"
|
||||
|
||||
## Requirements & Version
|
||||
* **Game Version:** 1.21.1
|
||||
* **Minecraft Version:** 1.20.1
|
||||
* **Java Version:** 17+
|
||||
* **Loader:** [Fabric Loader](https://fabricmc.net/)
|
||||
* **Dependencies:** [Fabric API](https://www.curseforge.com/minecraft/mc-mods/fabric-api)
|
||||
* **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.
|
||||
* **Dependency:** [Fabric API](https://www.curseforge.com/minecraft/mc-mods/fabric-api)
|
||||
* **Internet:** Required when using a remote AI provider
|
||||
* **AI Backend:** Supports local or self-hosted providers such as Ollama, Open-WebUI, or a custom OpenAI-compatible endpoint
|
||||
|
||||
## Usage
|
||||
There are three commands included in this mod:
|
||||
- `/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"!
|
||||
## Features
|
||||
- Ask questions directly from in-game chat with `/ai question <message>`
|
||||
- Spawn one or more AI companion entities with `/ai spawn`
|
||||
- Right-click a companion to open a persistent chat window for the current world session
|
||||
- Choose between different AI providers:
|
||||
Ollama, Open-WebUI, or a custom endpoint
|
||||
- Fetch available models directly from the configured provider
|
||||
- Switch between two AI behavior modes:
|
||||
`Casual` for a general assistant, or `Minecraft` to restrict answers to Minecraft-related topics
|
||||
- Change the selected model later without redoing the full setup
|
||||
- Delete only the stored API key, or reset the full AI configuration
|
||||
- Stored API keys are saved locally in config files and are encrypted when possible
|
||||
|
||||
## AI Companion Entity
|
||||
The AI Companion doesn't just stand around, he too has his own features:
|
||||
## Commands
|
||||
The mod currently includes these commands:
|
||||
|
||||
- `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.
|
||||
- `/ai question <question>`
|
||||
Sends a question to the configured AI provider and returns the answer in chat.
|
||||
- `/ai spawn`
|
||||
Spawns an AI companion entity at your position.
|
||||
- `/ai kill`
|
||||
Removes all spawned AI companions.
|
||||
- `/ai delete-key`
|
||||
Deletes the stored API key but keeps the rest of the provider configuration.
|
||||
- `/ai delete-config`
|
||||
Deletes the full saved AI configuration so setup starts from scratch next time.
|
||||
- `/ai model`
|
||||
Opens the model selection screen.
|
||||
- `/ai change-mode`
|
||||
Opens the mode selection screen.
|
||||
|
||||
Note: Response time depends on your provider, model, and hardware. Local models or busy servers may take longer to answer.
|
||||
|
||||
## AI Companion Entity
|
||||
The AI Companion is more than just a decoration:
|
||||
|
||||
- `Follow Owner`: The companion follows the player who spawned it.
|
||||
- `Right-Click Chat`: Interacting with the entity opens the companion chat screen.
|
||||
- `Persistent Session per World Join`: Companion chat history stays available until you leave the world/server.
|
||||
- `Invulnerable`: The entity cannot be damaged through normal gameplay.
|
||||
- `Custom Appearance`: The companion uses a custom player-style model and can temporarily switch appearance through a built-in easter egg.
|
||||
|
||||
## First-Time Setup
|
||||
The first time you use an AI feature, the setup screen opens automatically.
|
||||
|
||||
You can configure:
|
||||
- `Provider Preset`
|
||||
Choose `Ollama`, `Open-WebUI`, or `Custom`
|
||||
- `Base URL`
|
||||
Example: `http://localhost:11434` for Ollama
|
||||
- `API Key`
|
||||
Optional for local providers, required for protected remote providers
|
||||
- `Model`
|
||||
Fetch available models from the provider, or enter one manually
|
||||
- `Advanced API Path`
|
||||
Optional custom chat-completions path for OpenAI-compatible endpoints
|
||||
- `Mode`
|
||||
`Casual` or `Minecraft`
|
||||
|
||||
After saving, the mod stores the configuration in the `config/` folder and reuses it automatically.
|
||||
|
||||
## Provider Notes
|
||||
- `Ollama`
|
||||
Best for fully local use. Usually works with `http://localhost:11434` and supports model fetching from the setup screen.
|
||||
- `Open-WebUI`
|
||||
Works with Open-WebUI-compatible chat completion endpoints and model lists.
|
||||
- `Custom`
|
||||
Use this for any self-hosted or compatible provider with a custom base URL and optional custom API path.
|
||||
|
||||
If no custom API path is set, the client automatically tries standard chat-completions endpoints.
|
||||
|
||||
## 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)
|
||||
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.
|
||||
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>
|
||||
1. Install [Fabric Loader](https://fabricmc.net/use/installer/) for Minecraft 1.20.1 if you have not already done so.
|
||||
2. Download the mod `.jar` from the [Releases](https://github.com/Cametendo/minecraft-ai-companion-EMVs12-Project/releases) page.
|
||||
3. Place the `.jar` file into your Minecraft `mods` folder.
|
||||
4. Make sure [Fabric API](https://www.curseforge.com/minecraft/mc-mods/fabric-api) is installed as well.
|
||||
5. Start Minecraft with your Fabric 1.20.1 profile.
|
||||
|
||||
**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:** Support is only guaranteed for the official Java Edition + Fabric environment. Third-party launchers or unofficial game versions may work, but are not officially supported.
|
||||
|
||||
## 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!
|
||||
## API Key
|
||||
You only need an API key if your chosen AI provider requires one.
|
||||
|
||||
If you are using a hosted Open-WebUI instance such as ai.example.org, the general flow is:
|
||||
1. Create an account on the provider website.
|
||||
2. Open your account settings and generate or reveal an API key.
|
||||
3. Copy the key.
|
||||
4. In Minecraft, use any AI feature such as `/ai question <question>` or `/ai spawn`.
|
||||
5. When the setup screen opens, paste the API key into the `API Key` field.
|
||||
6. Choose the correct provider URL and model, then save the configuration.
|
||||
|
||||
If your provider does not require a key, you can leave that field empty.
|
||||
|
||||
## License & Credits
|
||||
* **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 mod in any modpack. Credits are appreciated, but not required.
|
||||
|
||||
NOT AN OFFICIAL MINECRAFT SERVICE. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT
|
||||
NOT AN OFFICIAL MINECRAFT SERVICE. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT.
|
||||
|
||||
@@ -6,7 +6,7 @@ minecraft_version=1.20.1
|
||||
yarn_mappings=1.20.1+build.10
|
||||
loader_version=0.18.4
|
||||
# Mod Properties
|
||||
mod_version=1.0-SNAPSHOT
|
||||
mod_version=1.0.1+1.20.1
|
||||
maven_group=AiCompanion
|
||||
archives_base_name=aicompanion2-0
|
||||
# Dependencies
|
||||
|
||||
@@ -2,6 +2,7 @@ package AiCompanion.aicompanion2_0.client;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
@@ -12,15 +13,17 @@ public class AiChatSession {
|
||||
private final String apiBaseUrl;
|
||||
private final String apiKey;
|
||||
private final String model;
|
||||
private final String apiPath; // null = auto-detect
|
||||
|
||||
// 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) {
|
||||
public AiChatSession(String apiBaseUrl, String apiKey, String model, String apiPath) {
|
||||
this.apiBaseUrl = apiBaseUrl;
|
||||
this.apiKey = apiKey;
|
||||
this.model = model;
|
||||
this.apiPath = (apiPath != null && !apiPath.isBlank()) ? apiPath : null;
|
||||
displayLines.add("§7Start a conversation with your AI companion!");
|
||||
}
|
||||
|
||||
@@ -50,36 +53,122 @@ public class AiChatSession {
|
||||
}
|
||||
|
||||
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 = buildChatRequestJson(false);
|
||||
|
||||
String json = "{\"model\":\"" + jsonEscape(model) + "\",\"messages\":" + messages + ",\"stream\":false}";
|
||||
if (apiPath != null) {
|
||||
// User configured a specific path — use it directly, no fallback
|
||||
HttpResult result = postChatCompletion(apiPath, json);
|
||||
if (result.status == 200) return extractAssistantContent(result.body);
|
||||
if (shouldRetryWithoutSystemPrompt(result)) {
|
||||
HttpResult retry = postChatCompletion(apiPath, buildChatRequestJson(true));
|
||||
if (retry.status == 200) return extractAssistantContent(retry.body);
|
||||
return formatHttpError(retry.status, retry.body, apiPath);
|
||||
}
|
||||
return formatHttpError(result.status, result.body, apiPath);
|
||||
}
|
||||
|
||||
// Auto-detect: try /api/chat/completions, fall back to /v1/chat/completions
|
||||
HttpResult primary = postChatCompletion("/api/chat/completions", json);
|
||||
if (primary.status == 200) {
|
||||
return extractAssistantContent(primary.body);
|
||||
}
|
||||
|
||||
if (shouldRetryWithoutSystemPrompt(primary)) {
|
||||
HttpResult retry = postChatCompletion("/api/chat/completions", buildChatRequestJson(true));
|
||||
if (retry.status == 200) {
|
||||
return extractAssistantContent(retry.body);
|
||||
}
|
||||
if (retry.status < 500 && retry.status != 404) {
|
||||
return formatHttpError(retry.status, retry.body, "/api/chat/completions");
|
||||
}
|
||||
}
|
||||
|
||||
if (primary.status >= 500 || primary.status == 404) {
|
||||
HttpResult fallback = postChatCompletion("/v1/chat/completions", json);
|
||||
if (fallback.status == 200) {
|
||||
return extractAssistantContent(fallback.body);
|
||||
}
|
||||
if (shouldRetryWithoutSystemPrompt(fallback)) {
|
||||
HttpResult retry = postChatCompletion("/v1/chat/completions", buildChatRequestJson(true));
|
||||
if (retry.status == 200) {
|
||||
return extractAssistantContent(retry.body);
|
||||
}
|
||||
return formatHttpError(retry.status, retry.body, "/v1/chat/completions");
|
||||
}
|
||||
return formatHttpError(fallback.status, fallback.body, "/v1/chat/completions");
|
||||
}
|
||||
|
||||
return formatHttpError(primary.status, primary.body, "/api/chat/completions");
|
||||
}
|
||||
|
||||
private String buildChatRequestJson(boolean inlineSystemPrompt) {
|
||||
String messages = buildMessagesJson(inlineSystemPrompt);
|
||||
return "{\"model\":\"" + jsonEscape(model) + "\",\"messages\":" + messages + ",\"stream\":false}";
|
||||
}
|
||||
|
||||
private String buildMessagesJson(boolean inlineSystemPrompt) {
|
||||
String systemPrompt = buildSystemPrompt();
|
||||
StringBuilder messages = new StringBuilder("[");
|
||||
|
||||
if (inlineSystemPrompt) {
|
||||
appendHistoryWithInlinePrompt(messages, systemPrompt);
|
||||
} else {
|
||||
messages.append("{\"role\":\"system\",\"content\":\"").append(jsonEscape(systemPrompt)).append("\"}");
|
||||
for (String[] msg : history) {
|
||||
messages.append(",{\"role\":\"").append(msg[0])
|
||||
.append("\",\"content\":\"").append(jsonEscape(msg[1]))
|
||||
.append("\"}");
|
||||
}
|
||||
}
|
||||
|
||||
messages.append("]");
|
||||
return messages.toString();
|
||||
}
|
||||
|
||||
private void appendHistoryWithInlinePrompt(StringBuilder messages, String systemPrompt) {
|
||||
boolean first = true;
|
||||
boolean injected = false;
|
||||
|
||||
for (String[] msg : history) {
|
||||
String role = msg[0];
|
||||
String content = msg[1];
|
||||
|
||||
if (!injected && "user".equals(role)) {
|
||||
content = systemPrompt + "\n\nUser message: " + content;
|
||||
injected = true;
|
||||
}
|
||||
|
||||
if (!first) {
|
||||
messages.append(",");
|
||||
}
|
||||
|
||||
messages.append("{\"role\":\"").append(role)
|
||||
.append("\",\"content\":\"").append(jsonEscape(content))
|
||||
.append("\"}");
|
||||
first = false;
|
||||
}
|
||||
|
||||
if (!injected) {
|
||||
messages.append("{\"role\":\"user\",\"content\":\"")
|
||||
.append(jsonEscape(systemPrompt))
|
||||
.append("\"}");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean shouldRetryWithoutSystemPrompt(HttpResult result) {
|
||||
if (result.status != 400 || result.body == null || result.body.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String normalized = result.body.toLowerCase();
|
||||
return normalized.contains("developer instruction")
|
||||
|| normalized.contains("system instruction")
|
||||
|| normalized.contains("system_message")
|
||||
|| normalized.contains("system message");
|
||||
}
|
||||
|
||||
private HttpResult postChatCompletion(String path, String json) throws Exception {
|
||||
URL url = new URL(apiBaseUrl + path);
|
||||
URL url = URI.create(apiBaseUrl + path).toURL();
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setRequestProperty("Content-Type", "application/json; charset=utf-8");
|
||||
@@ -130,6 +219,16 @@ public class AiChatSession {
|
||||
return "Error: HTTP " + status + " (" + path + ") - " + body;
|
||||
}
|
||||
|
||||
private String buildSystemPrompt() {
|
||||
if ("minecraft".equals(ClientConfig.getMode())) {
|
||||
return "You are a Minecraft-focused AI assistant. Always respond in the exact same language as the user's message. " +
|
||||
"Only answer Minecraft-related questions. If the user asks about anything unrelated to Minecraft, " +
|
||||
"respond in their language that you are only programmed to answer Minecraft-specific questions.";
|
||||
}
|
||||
return "You are a helpful AI companion. Always respond in the exact same language as the user's message. " +
|
||||
"If the user writes in romanized text (e.g. Japanese romaji), also include a translation in square brackets in English.";
|
||||
}
|
||||
|
||||
private String jsonEscape(String value) {
|
||||
if (value == null) return "";
|
||||
return value.replace("\\", "\\\\")
|
||||
|
||||
@@ -51,7 +51,7 @@ public class AiCompanionClient implements ClientModInitializer {
|
||||
|
||||
ClientPlayNetworking.registerGlobalReceiver(Aicompanion2_0.QUESTION_PACKET_ID, (client, handler, buf, responseSender) -> {
|
||||
String question = buf.readString(32767);
|
||||
client.execute(() -> ensureApiKeyAndRun(client, () -> askQuestion(client, question)));
|
||||
client.execute(() -> ensureSetupAndRun(client, () -> askQuestion(client, question)));
|
||||
});
|
||||
|
||||
ClientPlayNetworking.registerGlobalReceiver(Aicompanion2_0.DELETE_KEY_PACKET_ID, (client, handler, buf, responseSender) -> {
|
||||
@@ -72,6 +72,30 @@ public class AiCompanionClient implements ClientModInitializer {
|
||||
});
|
||||
});
|
||||
|
||||
ClientPlayNetworking.registerGlobalReceiver(Aicompanion2_0.DELETE_CONFIG_PACKET_ID, (client, handler, buf, responseSender) -> {
|
||||
client.execute(() -> {
|
||||
currentSession = null;
|
||||
try {
|
||||
ClientConfig.deleteConfig();
|
||||
sendChatMessage(client, Text.literal("§6[AI] §fConfig deleted. Run any AI command to set it up again."));
|
||||
} catch (Exception e) {
|
||||
String error = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName();
|
||||
sendChatMessage(client, Text.literal("§c[AI] Could not delete config: " + error));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ClientPlayNetworking.registerGlobalReceiver(Aicompanion2_0.MODEL_SELECT_PACKET_ID, (client, handler, buf, responseSender) -> {
|
||||
client.execute(() -> {
|
||||
currentSession = null;
|
||||
client.setScreen(new ModelSelectScreen());
|
||||
});
|
||||
});
|
||||
|
||||
ClientPlayNetworking.registerGlobalReceiver(Aicompanion2_0.CHANGE_MODE_PACKET_ID, (client, handler, buf, responseSender) -> {
|
||||
client.execute(() -> client.setScreen(new ModeSelectScreen()));
|
||||
});
|
||||
|
||||
ClientPlayNetworking.registerGlobalReceiver(Aicompanion2_0.ARCH_EASTER_EGG_RESPONSE_PACKET_ID, (client, handler, buf, responseSender) -> {
|
||||
String response = buf.readString(32767);
|
||||
client.execute(() -> finishArchEasterEgg(response));
|
||||
@@ -82,23 +106,22 @@ public class AiCompanionClient implements ClientModInitializer {
|
||||
if (world.isClient() && entity instanceof AIEntity) {
|
||||
MinecraftClient client = MinecraftClient.getInstance();
|
||||
|
||||
client.execute(() -> ensureApiKeyAndRun(client, () -> openChatScreen(client)));
|
||||
client.execute(() -> ensureSetupAndRun(client, () -> openChatScreen(client)));
|
||||
return ActionResult.SUCCESS;
|
||||
}
|
||||
return ActionResult.PASS;
|
||||
});
|
||||
}
|
||||
|
||||
private static void ensureApiKeyAndRun(MinecraftClient client, Runnable action) {
|
||||
if (!ClientConfig.hasApiKey()) {
|
||||
private static void ensureSetupAndRun(MinecraftClient client, Runnable action) {
|
||||
if (!ClientConfig.isSetupDone()) {
|
||||
currentSession = null;
|
||||
client.setScreen(new ApiKeyScreen(() -> {
|
||||
client.setScreen(new ProviderSetupScreen(() -> {
|
||||
currentSession = null;
|
||||
action.run();
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
action.run();
|
||||
}
|
||||
|
||||
@@ -129,9 +152,10 @@ public class AiCompanionClient implements ClientModInitializer {
|
||||
sendChatMessage(client, Text.literal("§6[AI] §fThink about: " + question));
|
||||
|
||||
AiChatSession session = new AiChatSession(
|
||||
Aicompanion2_0.getApiBaseUrl(),
|
||||
ClientConfig.getBaseUrl(),
|
||||
ClientConfig.getApiKey(),
|
||||
Aicompanion2_0.getModel()
|
||||
ClientConfig.getModel(),
|
||||
ClientConfig.getApiPath()
|
||||
);
|
||||
|
||||
session.sendMessage(question, response -> client.execute(() -> {
|
||||
@@ -177,9 +201,10 @@ public class AiCompanionClient implements ClientModInitializer {
|
||||
private static void openChatScreen(MinecraftClient client) {
|
||||
if (currentSession == null) {
|
||||
currentSession = new AiChatSession(
|
||||
Aicompanion2_0.getApiBaseUrl(),
|
||||
ClientConfig.getBaseUrl(),
|
||||
ClientConfig.getApiKey(),
|
||||
Aicompanion2_0.getModel()
|
||||
ClientConfig.getModel(),
|
||||
ClientConfig.getApiPath()
|
||||
);
|
||||
}
|
||||
client.setScreen(new AiChatScreen(currentSession));
|
||||
|
||||
@@ -13,7 +13,7 @@ public class ApiKeyScreen extends Screen {
|
||||
private final Runnable onSuccess;
|
||||
|
||||
public ApiKeyScreen(Runnable onSuccess) {
|
||||
super(Text.literal("Enter API Key (available on ai.cametendo.org)"));
|
||||
super(Text.literal("Enter API Key (available on ai.example.org)"));
|
||||
this.onSuccess = onSuccess;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,13 @@ 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;
|
||||
private static String baseUrl = null;
|
||||
private static String model = null;
|
||||
private static String apiPath = null;
|
||||
private static String mode = null;
|
||||
private static boolean loaded = false;
|
||||
|
||||
public static String getApiKey() {
|
||||
load();
|
||||
@@ -20,6 +26,34 @@ public class ClientConfig {
|
||||
save();
|
||||
}
|
||||
|
||||
/** Wipes all provider config (URL, model, key, path, mode) from memory and both config files. */
|
||||
public static void deleteConfig() throws IOException {
|
||||
apiKey = null;
|
||||
baseUrl = null;
|
||||
model = null;
|
||||
apiPath = null;
|
||||
mode = null;
|
||||
loaded = false;
|
||||
deleteConfigFrom(SHARED_CONFIG_PATH);
|
||||
deleteConfigFrom(CONFIG_PATH);
|
||||
}
|
||||
|
||||
private static void deleteConfigFrom(Path path) throws IOException {
|
||||
if (!Files.exists(path)) return;
|
||||
Properties props = new Properties();
|
||||
try (FileInputStream in = new FileInputStream(path.toFile())) {
|
||||
props.load(in);
|
||||
}
|
||||
props.remove("api.key");
|
||||
props.remove("api.baseUrl");
|
||||
props.remove("api.model");
|
||||
props.remove("api.path");
|
||||
props.remove("api.mode");
|
||||
try (FileOutputStream out = new FileOutputStream(path.toFile())) {
|
||||
props.store(out, "AI Companion Config");
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -32,12 +66,79 @@ public class ClientConfig {
|
||||
return apiKey != null && !apiKey.isBlank();
|
||||
}
|
||||
|
||||
public static String getBaseUrl() {
|
||||
if (!loaded) load();
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
public static String getModel() {
|
||||
if (!loaded) load();
|
||||
return model;
|
||||
}
|
||||
|
||||
public static String getApiPath() {
|
||||
if (!loaded) load();
|
||||
return apiPath;
|
||||
}
|
||||
|
||||
public static String getMode() {
|
||||
if (!loaded) load();
|
||||
return mode != null && !mode.isBlank() ? mode : "casual";
|
||||
}
|
||||
|
||||
public static void setMode(String m) {
|
||||
mode = (m != null && !m.isBlank()) ? m : "casual";
|
||||
save();
|
||||
}
|
||||
|
||||
public static boolean isSetupDone() {
|
||||
if (!loaded) load();
|
||||
return baseUrl != null && !baseUrl.isBlank() && model != null && !model.isBlank();
|
||||
}
|
||||
|
||||
public static void setProviderConfig(String url, String mdl, String key, String path) {
|
||||
baseUrl = url;
|
||||
model = mdl;
|
||||
apiKey = key != null ? key : "";
|
||||
apiPath = (path != null && !path.isBlank()) ? path : null;
|
||||
loaded = true;
|
||||
save();
|
||||
}
|
||||
|
||||
public static void setProviderConfig(String url, String mdl, String key, String path, String newMode) {
|
||||
baseUrl = url;
|
||||
model = mdl;
|
||||
apiKey = key != null ? key : "";
|
||||
apiPath = (path != null && !path.isBlank()) ? path : null;
|
||||
mode = (newMode != null && !newMode.isBlank()) ? newMode : "casual";
|
||||
loaded = true;
|
||||
save();
|
||||
}
|
||||
|
||||
private static void load() {
|
||||
loaded = true;
|
||||
// Remove the old plain-text key file if it still exists from a previous version
|
||||
try { Files.deleteIfExists(Path.of("config", "aicompanion2_0.key")); } catch (Exception ignored) {}
|
||||
try {
|
||||
// Prefer shared config so GUI and /ai frage use the same key source.
|
||||
apiKey = loadApiKeyFrom(SHARED_CONFIG_PATH);
|
||||
apiKey = decryptKey(loadProp(SHARED_CONFIG_PATH, "api.key"));
|
||||
if (apiKey == null || apiKey.isBlank()) {
|
||||
apiKey = loadApiKeyFrom(CONFIG_PATH);
|
||||
apiKey = decryptKey(loadProp(CONFIG_PATH, "api.key"));
|
||||
}
|
||||
baseUrl = loadProp(SHARED_CONFIG_PATH, "api.baseUrl");
|
||||
if (baseUrl == null || baseUrl.isBlank()) {
|
||||
baseUrl = loadProp(CONFIG_PATH, "api.baseUrl");
|
||||
}
|
||||
model = loadProp(SHARED_CONFIG_PATH, "api.model");
|
||||
if (model == null || model.isBlank()) {
|
||||
model = loadProp(CONFIG_PATH, "api.model");
|
||||
}
|
||||
apiPath = loadProp(SHARED_CONFIG_PATH, "api.path");
|
||||
if (apiPath == null || apiPath.isBlank()) {
|
||||
apiPath = loadProp(CONFIG_PATH, "api.path");
|
||||
}
|
||||
mode = loadProp(SHARED_CONFIG_PATH, "api.mode");
|
||||
if (mode == null || mode.isBlank()) {
|
||||
mode = loadProp(CONFIG_PATH, "api.mode");
|
||||
}
|
||||
} catch (IOException e) {
|
||||
apiKey = null;
|
||||
@@ -45,25 +146,35 @@ public class ClientConfig {
|
||||
}
|
||||
}
|
||||
|
||||
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] Could not save Client Config.");
|
||||
/** Decrypts an ENC:-prefixed value; returns plaintext as-is for legacy configs. */
|
||||
private static String decryptKey(String raw) {
|
||||
if (raw == null || raw.isBlank()) return null;
|
||||
if (raw.startsWith(CryptoUtil.PREFIX)) {
|
||||
return CryptoUtil.decrypt(raw.substring(CryptoUtil.PREFIX.length()));
|
||||
}
|
||||
// Legacy plaintext — will be re-saved encrypted on next write
|
||||
return raw;
|
||||
}
|
||||
|
||||
private static String loadApiKeyFrom(Path path) throws IOException {
|
||||
private static String loadProp(Path path, String key) 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);
|
||||
return props.getProperty(key, null);
|
||||
}
|
||||
|
||||
private static void saveApiKeyTo(Path path, String comment) throws IOException {
|
||||
private static void save() {
|
||||
try {
|
||||
saveTo(CONFIG_PATH, "AI Companion Client Config");
|
||||
saveTo(SHARED_CONFIG_PATH, "AI Companion Shared Config");
|
||||
} catch (IOException e) {
|
||||
System.out.println("[aicompanion2_0] Konnte Client-Config nicht speichern.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void saveTo(Path path, String comment) throws IOException {
|
||||
Files.createDirectories(path.getParent());
|
||||
Properties props = new Properties();
|
||||
if (Files.exists(path)) {
|
||||
@@ -71,7 +182,19 @@ public class ClientConfig {
|
||||
props.load(in);
|
||||
}
|
||||
}
|
||||
props.setProperty("api.key", apiKey != null ? apiKey : "");
|
||||
String keyToStore = "";
|
||||
if (apiKey != null && !apiKey.isBlank()) {
|
||||
try {
|
||||
keyToStore = CryptoUtil.PREFIX + CryptoUtil.encrypt(apiKey);
|
||||
} catch (Exception e) {
|
||||
keyToStore = apiKey; // fallback: store plaintext if encryption fails
|
||||
}
|
||||
}
|
||||
props.setProperty("api.key", keyToStore);
|
||||
if (baseUrl != null) props.setProperty("api.baseUrl", baseUrl);
|
||||
if (model != null) props.setProperty("api.model", model);
|
||||
if (apiPath != null) props.setProperty("api.path", apiPath);
|
||||
if (mode != null) props.setProperty("api.mode", mode);
|
||||
try (FileOutputStream out = new FileOutputStream(path.toFile())) {
|
||||
props.store(out, comment);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package AiCompanion.aicompanion2_0.client;
|
||||
|
||||
import javax.crypto.*;
|
||||
import javax.crypto.spec.*;
|
||||
import java.net.InetAddress;
|
||||
import java.net.NetworkInterface;
|
||||
import java.security.*;
|
||||
import java.security.spec.KeySpec;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* AES-256-GCM encryption with a key derived from machine-specific data via PBKDF2.
|
||||
* No key file is written to disk — the encryption key is re-derived on each session
|
||||
* from identifiers tied to the current user and machine, making the stored ciphertext
|
||||
* useless on any other machine or user account.
|
||||
*/
|
||||
public class CryptoUtil {
|
||||
|
||||
static final String PREFIX = "ENC:";
|
||||
|
||||
private static final int IV_LEN = 12;
|
||||
private static final int TAG_BITS = 128;
|
||||
private static final int PBKDF2_ITERATIONS = 65536;
|
||||
|
||||
// Fixed, non-secret salt — prevents pre-computation attacks.
|
||||
private static final byte[] SALT = {
|
||||
0x4d, 0x43, 0x41, 0x49, 0x5f, 0x73, 0x61, 0x6c,
|
||||
0x74, 0x5f, 0x76, 0x31, 0x5f, 0x78, 0x79, 0x7a
|
||||
};
|
||||
|
||||
private static SecretKey cachedKey = null;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private static SecretKey deriveKey() throws Exception {
|
||||
if (cachedKey != null) return cachedKey;
|
||||
String machineId = buildMachineId();
|
||||
SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
|
||||
KeySpec spec = new PBEKeySpec(machineId.toCharArray(), SALT, PBKDF2_ITERATIONS, 256);
|
||||
byte[] keyBytes = factory.generateSecret(spec).getEncoded();
|
||||
cachedKey = new SecretKeySpec(keyBytes, "AES");
|
||||
return cachedKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a stable identifier from OS username, home directory path, hostname,
|
||||
* and MAC address. Changing any of these (e.g. renaming the user) will invalidate
|
||||
* previously encrypted values — intentional machine-binding behaviour.
|
||||
*/
|
||||
private static String buildMachineId() {
|
||||
StringBuilder id = new StringBuilder();
|
||||
id.append(System.getProperty("user.name", "?"));
|
||||
id.append('\0');
|
||||
id.append(System.getProperty("user.home", "?"));
|
||||
id.append('\0');
|
||||
try {
|
||||
id.append(InetAddress.getLocalHost().getHostName());
|
||||
} catch (Exception e) {
|
||||
id.append("localhost");
|
||||
}
|
||||
// MAC address for stronger hardware binding
|
||||
try {
|
||||
NetworkInterface ni = NetworkInterface.getByInetAddress(InetAddress.getLocalHost());
|
||||
if (ni != null) {
|
||||
byte[] mac = ni.getHardwareAddress();
|
||||
if (mac != null) {
|
||||
id.append('\0');
|
||||
id.append(Base64.getEncoder().encodeToString(mac));
|
||||
}
|
||||
}
|
||||
} catch (Exception ignored) {}
|
||||
return id.toString();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Encrypts {@code plaintext} and returns a Base64 string (IV prepended to ciphertext). */
|
||||
public static String encrypt(String plaintext) throws Exception {
|
||||
SecretKey key = deriveKey();
|
||||
byte[] iv = new byte[IV_LEN];
|
||||
new SecureRandom().nextBytes(iv);
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv));
|
||||
byte[] ct = cipher.doFinal(plaintext.getBytes("UTF-8"));
|
||||
byte[] out = new byte[IV_LEN + ct.length];
|
||||
System.arraycopy(iv, 0, out, 0, IV_LEN);
|
||||
System.arraycopy(ct, 0, out, IV_LEN, ct.length);
|
||||
return Base64.getEncoder().encodeToString(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts a Base64 string produced by {@link #encrypt}.
|
||||
* Returns {@code null} on failure (wrong machine, corrupt data, etc.).
|
||||
*/
|
||||
public static String decrypt(String encoded) {
|
||||
try {
|
||||
SecretKey key = deriveKey();
|
||||
byte[] combined = Base64.getDecoder().decode(encoded);
|
||||
byte[] iv = new byte[IV_LEN];
|
||||
byte[] ct = new byte[combined.length - IV_LEN];
|
||||
System.arraycopy(combined, 0, iv, 0, IV_LEN);
|
||||
System.arraycopy(combined, IV_LEN, ct, 0, ct.length);
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv));
|
||||
return new String(cipher.doFinal(ct), "UTF-8");
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
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.text.Text;
|
||||
|
||||
public class ModeSelectScreen extends Screen {
|
||||
|
||||
public ModeSelectScreen() {
|
||||
super(Text.literal("Change AI Mode"));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void init() {
|
||||
int cx = width / 2;
|
||||
int cy = height / 2;
|
||||
|
||||
boolean casualSelected = !"minecraft".equals(ClientConfig.getMode());
|
||||
boolean mcSelected = "minecraft".equals(ClientConfig.getMode());
|
||||
|
||||
addDrawableChild(ButtonWidget.builder(
|
||||
Text.literal(casualSelected ? "§a▶ Casual" : " Casual"),
|
||||
btn -> { ClientConfig.setMode("casual"); close(); }
|
||||
).dimensions(cx - 75, cy - 20, 150, 20).build());
|
||||
|
||||
addDrawableChild(ButtonWidget.builder(
|
||||
Text.literal(mcSelected ? "§a▶ Minecraft" : " Minecraft"),
|
||||
btn -> { ClientConfig.setMode("minecraft"); close(); }
|
||||
).dimensions(cx - 75, cy + 5, 150, 20).build());
|
||||
|
||||
addDrawableChild(ButtonWidget.builder(Text.literal("Cancel"), btn -> close())
|
||||
.dimensions(cx - 55, cy + 32, 110, 20).build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(DrawContext context, int mouseX, int mouseY, float delta) {
|
||||
renderBackground(context);
|
||||
int cx = width / 2;
|
||||
int cy = height / 2;
|
||||
|
||||
context.drawCenteredTextWithShadow(textRenderer, "§6Change AI Mode", cx, cy - 48, 0xFFFFFF);
|
||||
context.drawCenteredTextWithShadow(textRenderer,
|
||||
"§8Current: §7" + ClientConfig.getMode(), cx, cy - 36, 0x888888);
|
||||
context.drawCenteredTextWithShadow(textRenderer,
|
||||
"§8Casual §7- general assistant, matches your language",
|
||||
cx, cy - 24, 0x666666);
|
||||
context.drawCenteredTextWithShadow(textRenderer,
|
||||
"§8Minecraft §7- only answers Minecraft questions",
|
||||
cx, cy - 14, 0x666666);
|
||||
|
||||
super.render(context, mouseX, mouseY, delta);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldPause() { return false; }
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
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;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class ModelSelectScreen extends Screen {
|
||||
|
||||
private enum FetchState { IDLE, LOADING, SUCCESS, ERROR }
|
||||
private FetchState fetchState = FetchState.IDLE;
|
||||
private List<String> fetchedModels = new ArrayList<>();
|
||||
private String fetchError = null;
|
||||
private int scrollOffset = 0;
|
||||
|
||||
private String savedModel;
|
||||
private String savedMode;
|
||||
private TextFieldWidget modelField;
|
||||
|
||||
private static final int MAX_VISIBLE = 5;
|
||||
private static final int ITEM_H = 16;
|
||||
|
||||
public ModelSelectScreen() {
|
||||
super(Text.literal("Change Model"));
|
||||
this.savedModel = ClientConfig.getModel() != null ? ClientConfig.getModel() : "";
|
||||
this.savedMode = ClientConfig.getMode();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void init() {
|
||||
modelField = null;
|
||||
|
||||
int cx = width / 2;
|
||||
int cy = height / 2;
|
||||
|
||||
// Fetch button
|
||||
String fetchLabel = switch (fetchState) {
|
||||
case LOADING -> "§7Fetching...";
|
||||
case SUCCESS -> "↻ Refetch";
|
||||
case ERROR -> "↻ Retry";
|
||||
default -> "Fetch Models";
|
||||
};
|
||||
addDrawableChild(ButtonWidget.builder(Text.literal(fetchLabel), btn -> {
|
||||
if (fetchState != FetchState.LOADING) startFetch();
|
||||
}).dimensions(cx - 75, cy - 55, 150, 20).build());
|
||||
|
||||
// Model list or manual text field
|
||||
boolean hasList = fetchState == FetchState.SUCCESS && !fetchedModels.isEmpty();
|
||||
int listStartY = cy - 28;
|
||||
|
||||
if (hasList) {
|
||||
int visible = Math.min(fetchedModels.size(), MAX_VISIBLE);
|
||||
int end = Math.min(fetchedModels.size(), scrollOffset + visible);
|
||||
for (int i = scrollOffset; i < end; i++) {
|
||||
String m = fetchedModels.get(i);
|
||||
boolean selected = m.equals(savedModel);
|
||||
final String model = m;
|
||||
addDrawableChild(ButtonWidget.builder(
|
||||
Text.literal((selected ? "§a▶ " : " ") + m),
|
||||
btn -> { savedModel = model; reinit(); }
|
||||
).dimensions(cx - 150, listStartY + (i - scrollOffset) * ITEM_H, 290, ITEM_H - 2).build());
|
||||
}
|
||||
if (fetchedModels.size() > MAX_VISIBLE) {
|
||||
addDrawableChild(ButtonWidget.builder(Text.literal("▲"),
|
||||
btn -> { scrollOffset = Math.max(0, scrollOffset - 1); reinit(); }
|
||||
).dimensions(cx + 143, listStartY, 7, ITEM_H - 2).build());
|
||||
addDrawableChild(ButtonWidget.builder(Text.literal("▼"),
|
||||
btn -> { scrollOffset = Math.min(fetchedModels.size() - MAX_VISIBLE, scrollOffset + 1); reinit(); }
|
||||
).dimensions(cx + 143, listStartY + ITEM_H, 7, ITEM_H - 2).build());
|
||||
}
|
||||
} else {
|
||||
modelField = new TextFieldWidget(textRenderer, cx - 150, listStartY, 300, 20, Text.literal("Model name..."));
|
||||
modelField.setMaxLength(100);
|
||||
modelField.setText(savedModel);
|
||||
modelField.setFocused(true);
|
||||
addDrawableChild(modelField);
|
||||
}
|
||||
|
||||
int listH = hasList ? Math.min(fetchedModels.size(), MAX_VISIBLE) * ITEM_H : 22;
|
||||
int modeY = listStartY + listH + 8;
|
||||
|
||||
boolean casualSelected = !"minecraft".equals(savedMode);
|
||||
boolean mcSelected = "minecraft".equals(savedMode);
|
||||
addDrawableChild(ButtonWidget.builder(
|
||||
Text.literal(casualSelected ? "§a▶ Casual" : " Casual"),
|
||||
btn -> { savedMode = "casual"; reinit(); }
|
||||
).dimensions(cx - 110, modeY, 100, 12).build());
|
||||
addDrawableChild(ButtonWidget.builder(
|
||||
Text.literal(mcSelected ? "§a▶ Minecraft" : " Minecraft"),
|
||||
btn -> { savedMode = "minecraft"; reinit(); }
|
||||
).dimensions(cx - 5, modeY, 110, 12).build());
|
||||
|
||||
int saveY = modeY + 18;
|
||||
addDrawableChild(ButtonWidget.builder(Text.literal("Save"), btn -> confirm())
|
||||
.dimensions(cx - 55, saveY, 110, 20).build());
|
||||
addDrawableChild(ButtonWidget.builder(Text.literal("Cancel"), btn -> close())
|
||||
.dimensions(cx - 55, saveY + 24, 110, 20).build());
|
||||
}
|
||||
|
||||
private void reinit() {
|
||||
if (modelField != null) savedModel = modelField.getText();
|
||||
MinecraftClient mc = MinecraftClient.getInstance();
|
||||
this.init(mc, mc.getWindow().getScaledWidth(), mc.getWindow().getScaledHeight());
|
||||
}
|
||||
|
||||
private void confirm() {
|
||||
if (modelField != null) savedModel = modelField.getText();
|
||||
String model = savedModel.trim();
|
||||
if (model.isEmpty()) return;
|
||||
ClientConfig.setProviderConfig(
|
||||
ClientConfig.getBaseUrl(),
|
||||
model,
|
||||
ClientConfig.getApiKey(),
|
||||
ClientConfig.getApiPath(),
|
||||
savedMode
|
||||
);
|
||||
close();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Fetching
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private void startFetch() {
|
||||
if (modelField != null) savedModel = modelField.getText();
|
||||
fetchState = FetchState.LOADING;
|
||||
fetchError = null;
|
||||
reinit();
|
||||
|
||||
String url = ClientConfig.getBaseUrl();
|
||||
String key = ClientConfig.getApiKey();
|
||||
|
||||
new Thread(() -> {
|
||||
try {
|
||||
List<String> models = fetchModels(url, key);
|
||||
if (models.isEmpty()) {
|
||||
fetchState = FetchState.ERROR;
|
||||
fetchError = "No models found";
|
||||
} else {
|
||||
fetchedModels = models;
|
||||
fetchState = FetchState.SUCCESS;
|
||||
scrollOffset = 0;
|
||||
if (savedModel.isEmpty()) savedModel = models.get(0);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
fetchState = FetchState.ERROR;
|
||||
fetchError = e.getMessage() != null ? e.getMessage() : "Connection failed";
|
||||
}
|
||||
MinecraftClient.getInstance().execute(this::reinit);
|
||||
}).start();
|
||||
}
|
||||
|
||||
private List<String> fetchModels(String baseUrl, String apiKey) throws Exception {
|
||||
try {
|
||||
List<String> m = fetchFromPath(baseUrl, "/api/tags", apiKey, "name");
|
||||
if (!m.isEmpty()) return m;
|
||||
} catch (Exception ignored) {}
|
||||
try {
|
||||
List<String> m = fetchFromPath(baseUrl, "/v1/models", apiKey, "id");
|
||||
if (!m.isEmpty()) return m;
|
||||
} catch (Exception ignored) {}
|
||||
return fetchFromPath(baseUrl, "/api/models", apiKey, "id");
|
||||
}
|
||||
|
||||
private List<String> fetchFromPath(String baseUrl, String path, String apiKey, String field) throws Exception {
|
||||
URL url = URI.create(baseUrl + path).toURL();
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setRequestMethod("GET");
|
||||
conn.setConnectTimeout(5000);
|
||||
conn.setReadTimeout(5000);
|
||||
if (apiKey != null && !apiKey.isBlank()) {
|
||||
conn.setRequestProperty("Authorization", "Bearer " + apiKey);
|
||||
}
|
||||
int status = conn.getResponseCode();
|
||||
if (status != 200) throw new Exception("HTTP " + status + " from " + path);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream(), "utf-8"))) {
|
||||
String line;
|
||||
while ((line = br.readLine()) != null) sb.append(line);
|
||||
}
|
||||
conn.disconnect();
|
||||
|
||||
List<String> result = new ArrayList<>();
|
||||
String key = "\"" + field + "\"";
|
||||
String body = sb.toString();
|
||||
int idx = 0;
|
||||
while ((idx = body.indexOf(key, idx)) >= 0) {
|
||||
idx += key.length();
|
||||
while (idx < body.length() && (body.charAt(idx) == ' ' || body.charAt(idx) == '\t' || body.charAt(idx) == ':')) idx++;
|
||||
if (idx < body.length() && body.charAt(idx) == '"') {
|
||||
int start = idx + 1;
|
||||
int end = body.indexOf('"', start);
|
||||
if (end > start) result.add(body.substring(start, end));
|
||||
idx = end + 1;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Override
|
||||
public void render(DrawContext context, int mouseX, int mouseY, float delta) {
|
||||
renderBackground(context);
|
||||
int cx = width / 2;
|
||||
int cy = height / 2;
|
||||
|
||||
context.drawCenteredTextWithShadow(textRenderer, "§6Change Model", cx, cy - 75, 0xFFFFFF);
|
||||
context.drawCenteredTextWithShadow(textRenderer,
|
||||
"§8Current: §7" + (ClientConfig.getModel() != null ? ClientConfig.getModel() : "none"),
|
||||
cx, cy - 63, 0x888888);
|
||||
context.drawTextWithShadow(textRenderer, "§7Model:", cx - 150, cy - 30, 0xAAAAAA);
|
||||
|
||||
int listH = (fetchState == FetchState.SUCCESS && !fetchedModels.isEmpty())
|
||||
? Math.min(fetchedModels.size(), MAX_VISIBLE) * ITEM_H : 22;
|
||||
int modeY = (cy - 28) + listH + 8;
|
||||
context.drawTextWithShadow(textRenderer, "§7Mode:", cx - 110, modeY + 2, 0xAAAAAA);
|
||||
|
||||
if (fetchState == FetchState.LOADING) {
|
||||
context.drawTextWithShadow(textRenderer, "§7Fetching...", cx - 150, cy - 14, 0x888888);
|
||||
} else if (fetchState == FetchState.ERROR && fetchError != null) {
|
||||
context.drawTextWithShadow(textRenderer, "§c" + fetchError, cx - 150, cy - 14, 0xFF5555);
|
||||
}
|
||||
|
||||
super.render(context, mouseX, mouseY, delta);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseScrolled(double mouseX, double mouseY, double amount) {
|
||||
if (fetchState == FetchState.SUCCESS && fetchedModels.size() > MAX_VISIBLE) {
|
||||
scrollOffset = Math.max(0, Math.min(
|
||||
fetchedModels.size() - MAX_VISIBLE,
|
||||
scrollOffset - (int) Math.signum(amount)
|
||||
));
|
||||
reinit();
|
||||
return true;
|
||||
}
|
||||
return super.mouseScrolled(mouseX, mouseY, amount);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldPause() { return false; }
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
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.OrderedText;
|
||||
import net.minecraft.text.Style;
|
||||
import net.minecraft.text.Text;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStreamReader;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URI;
|
||||
import java.net.URL;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
public class ProviderSetupScreen extends Screen {
|
||||
|
||||
private boolean advancedExpanded = false;
|
||||
|
||||
private enum FetchState { IDLE, LOADING, SUCCESS, ERROR }
|
||||
private FetchState fetchState = FetchState.IDLE;
|
||||
private List<String> fetchedModels = new ArrayList<>();
|
||||
private String fetchError = null;
|
||||
private int modelScrollOffset = 0;
|
||||
|
||||
// Values preserved across reinit() calls
|
||||
private String savedUrl = "http://localhost:11434";
|
||||
private String savedKey = "";
|
||||
private String savedModel = "";
|
||||
private String savedPath = "";
|
||||
private String savedMode = ClientConfig.getMode();
|
||||
|
||||
private TextFieldWidget urlField;
|
||||
private TextFieldWidget keyField;
|
||||
private TextFieldWidget modelField;
|
||||
private TextFieldWidget pathField;
|
||||
|
||||
private final Runnable onSuccess;
|
||||
|
||||
private static final int MAX_VISIBLE = 3;
|
||||
private static final int ITEM_H = 14;
|
||||
|
||||
// Layout Y offsets relative to baseY (= height/2 - 100)
|
||||
private static final int Y_PRESETS = 20; // preset buttons
|
||||
private static final int Y_URL_FIELD = 50; // URL text field
|
||||
private static final int Y_KEY_FIELD = 85; // key text field
|
||||
private static final int Y_MODEL_ROW = 110; // "Model:" label + fetch button
|
||||
private static final int Y_MODEL_CONT = 123; // model list / text field
|
||||
|
||||
public ProviderSetupScreen(Runnable onSuccess) {
|
||||
super(Text.literal("AI Companion Setup"));
|
||||
this.onSuccess = onSuccess;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void init() {
|
||||
urlField = null;
|
||||
keyField = null;
|
||||
modelField = null;
|
||||
pathField = null;
|
||||
|
||||
int cx = width / 2;
|
||||
int baseY = height / 2 - 100;
|
||||
|
||||
// --- Preset buttons (pre-fill URL) ---
|
||||
addDrawableChild(ButtonWidget.builder(Text.literal("Ollama"), btn -> {
|
||||
saveCurrentValues();
|
||||
savedUrl = "http://localhost:11434";
|
||||
reinit();
|
||||
}).dimensions(cx - 155, baseY + Y_PRESETS, 95, 14).build());
|
||||
|
||||
addDrawableChild(ButtonWidget.builder(Text.literal("Open-WebUI"), btn -> {
|
||||
saveCurrentValues();
|
||||
savedUrl = "https://ai.example.org";
|
||||
savedPath = "/api/chat/completions";
|
||||
advancedExpanded = true;
|
||||
reinit();
|
||||
}).dimensions(cx - 50, baseY + Y_PRESETS, 95, 14).build());
|
||||
|
||||
addDrawableChild(ButtonWidget.builder(Text.literal("Custom"), btn -> {
|
||||
saveCurrentValues();
|
||||
savedUrl = "";
|
||||
reinit();
|
||||
}).dimensions(cx + 55, baseY + Y_PRESETS, 95, 14).build());
|
||||
|
||||
// --- URL field ---
|
||||
urlField = new TextFieldWidget(textRenderer, cx - 150, baseY + Y_URL_FIELD, 300, 20, Text.literal("http://..."));
|
||||
urlField.setMaxLength(200);
|
||||
urlField.setText(savedUrl);
|
||||
addDrawableChild(urlField);
|
||||
|
||||
// --- API key field (password-masked, optional) ---
|
||||
keyField = new TextFieldWidget(textRenderer, cx - 150, baseY + Y_KEY_FIELD, 300, 20, Text.literal("API Key (optional)"));
|
||||
keyField.setMaxLength(256);
|
||||
keyField.setText(savedKey);
|
||||
keyField.setRenderTextProvider((text, offset) ->
|
||||
OrderedText.styledForwardsVisitedString("•".repeat(text.length()), Style.EMPTY));
|
||||
addDrawableChild(keyField);
|
||||
|
||||
// --- Fetch button (right side of model row) ---
|
||||
String fetchLabel = switch (fetchState) {
|
||||
case LOADING -> "§7Fetching...";
|
||||
case SUCCESS -> "↻ Refetch";
|
||||
case ERROR -> "↻ Retry";
|
||||
default -> "Fetch Models";
|
||||
};
|
||||
addDrawableChild(ButtonWidget.builder(Text.literal(fetchLabel), btn -> {
|
||||
if (fetchState != FetchState.LOADING) {
|
||||
saveCurrentValues();
|
||||
startFetch();
|
||||
}
|
||||
}).dimensions(cx + 5, baseY + Y_MODEL_ROW, 145, 12).build());
|
||||
|
||||
// --- Model area ---
|
||||
boolean hasList = fetchState == FetchState.SUCCESS && !fetchedModels.isEmpty();
|
||||
if (hasList) {
|
||||
int visible = Math.min(fetchedModels.size(), MAX_VISIBLE);
|
||||
int end = Math.min(fetchedModels.size(), modelScrollOffset + visible);
|
||||
for (int i = modelScrollOffset; i < end; i++) {
|
||||
String m = fetchedModels.get(i);
|
||||
boolean selected = m.equals(savedModel);
|
||||
final String model = m;
|
||||
addDrawableChild(ButtonWidget.builder(
|
||||
Text.literal((selected ? "§a▶ " : " ") + m),
|
||||
btn -> { savedModel = model; reinit(); }
|
||||
).dimensions(cx - 150, baseY + Y_MODEL_CONT + (i - modelScrollOffset) * ITEM_H, 290, ITEM_H - 2).build());
|
||||
}
|
||||
if (fetchedModels.size() > MAX_VISIBLE) {
|
||||
addDrawableChild(ButtonWidget.builder(Text.literal("▲"),
|
||||
btn -> { modelScrollOffset = Math.max(0, modelScrollOffset - 1); reinit(); }
|
||||
).dimensions(cx + 143, baseY + Y_MODEL_CONT, 7, ITEM_H - 2).build());
|
||||
addDrawableChild(ButtonWidget.builder(Text.literal("▼"),
|
||||
btn -> { modelScrollOffset = Math.min(fetchedModels.size() - MAX_VISIBLE, modelScrollOffset + 1); reinit(); }
|
||||
).dimensions(cx + 143, baseY + Y_MODEL_CONT + ITEM_H, 7, ITEM_H - 2).build());
|
||||
}
|
||||
} else {
|
||||
modelField = new TextFieldWidget(textRenderer, cx - 150, baseY + Y_MODEL_CONT, 300, 20, Text.literal("Model name..."));
|
||||
modelField.setMaxLength(100);
|
||||
modelField.setText(savedModel);
|
||||
addDrawableChild(modelField);
|
||||
}
|
||||
|
||||
int listH = hasList ? Math.min(fetchedModels.size(), MAX_VISIBLE) * ITEM_H : 22;
|
||||
int afterModelY = baseY + Y_MODEL_CONT + listH + 4;
|
||||
|
||||
// --- Advanced toggle ---
|
||||
String advLabel = advancedExpanded ? "Advanced ▲" : "Advanced ▼";
|
||||
addDrawableChild(ButtonWidget.builder(Text.literal("§8" + advLabel), btn -> {
|
||||
saveCurrentValues();
|
||||
advancedExpanded = !advancedExpanded;
|
||||
reinit();
|
||||
}).dimensions(cx + 55, afterModelY, 95, 12).build());
|
||||
|
||||
// --- Advanced: API path ---
|
||||
int saveY;
|
||||
if (advancedExpanded) {
|
||||
pathField = new TextFieldWidget(textRenderer, cx - 150, afterModelY + 18, 300, 20, Text.literal("/api/chat/completions"));
|
||||
pathField.setMaxLength(200);
|
||||
pathField.setText(savedPath);
|
||||
addDrawableChild(pathField);
|
||||
saveY = afterModelY + 45;
|
||||
} else {
|
||||
saveY = afterModelY + 18;
|
||||
}
|
||||
|
||||
// --- Mode selector ---
|
||||
boolean casualSelected = !"minecraft".equals(savedMode);
|
||||
boolean mcSelected = "minecraft".equals(savedMode);
|
||||
addDrawableChild(ButtonWidget.builder(
|
||||
Text.literal(casualSelected ? "§a▶ Casual" : " Casual"),
|
||||
btn -> { saveCurrentValues(); savedMode = "casual"; reinit(); }
|
||||
).dimensions(cx - 155, saveY, 95, 12).build());
|
||||
addDrawableChild(ButtonWidget.builder(
|
||||
Text.literal(mcSelected ? "§a▶ Minecraft" : " Minecraft"),
|
||||
btn -> { saveCurrentValues(); savedMode = "minecraft"; reinit(); }
|
||||
).dimensions(cx - 50, saveY, 100, 12).build());
|
||||
|
||||
// --- Save button ---
|
||||
addDrawableChild(ButtonWidget.builder(Text.literal("Save & Connect"), btn -> confirm())
|
||||
.dimensions(cx - 75, saveY + 18, 150, 20).build());
|
||||
|
||||
urlField.setFocused(true);
|
||||
}
|
||||
|
||||
private void saveCurrentValues() {
|
||||
if (urlField != null) savedUrl = urlField.getText();
|
||||
if (keyField != null) savedKey = keyField.getText();
|
||||
if (modelField != null) savedModel = modelField.getText();
|
||||
if (pathField != null) savedPath = pathField.getText();
|
||||
}
|
||||
|
||||
private void reinit() {
|
||||
MinecraftClient mc = MinecraftClient.getInstance();
|
||||
this.init(mc, mc.getWindow().getScaledWidth(), mc.getWindow().getScaledHeight());
|
||||
}
|
||||
|
||||
private void confirm() {
|
||||
saveCurrentValues();
|
||||
String url = savedUrl.trim();
|
||||
String model = savedModel.trim();
|
||||
String key = savedKey.trim();
|
||||
String path = savedPath.trim();
|
||||
if (url.isEmpty() || model.isEmpty()) return;
|
||||
ClientConfig.setProviderConfig(url, model, key.isEmpty() ? null : key, path.isEmpty() ? null : path, savedMode);
|
||||
MinecraftClient.getInstance().execute(() -> {
|
||||
close();
|
||||
onSuccess.run();
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Model fetching
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private void startFetch() {
|
||||
fetchState = FetchState.LOADING;
|
||||
fetchError = null;
|
||||
String url = savedUrl.trim().isEmpty() ? "http://localhost:11434" : savedUrl.trim();
|
||||
String key = savedKey.trim();
|
||||
reinit();
|
||||
|
||||
new Thread(() -> {
|
||||
try {
|
||||
List<String> models = fetchModels(url, key);
|
||||
if (models.isEmpty()) {
|
||||
fetchState = FetchState.ERROR;
|
||||
fetchError = "No models found";
|
||||
} else {
|
||||
fetchedModels = models;
|
||||
fetchState = FetchState.SUCCESS;
|
||||
modelScrollOffset = 0;
|
||||
if (savedModel.isEmpty()) savedModel = models.get(0);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
fetchState = FetchState.ERROR;
|
||||
fetchError = e.getMessage() != null ? e.getMessage() : "Connection failed";
|
||||
}
|
||||
MinecraftClient.getInstance().execute(this::reinit);
|
||||
}).start();
|
||||
}
|
||||
|
||||
private List<String> fetchModels(String baseUrl, String apiKey) throws Exception {
|
||||
// Ollama native: GET /api/tags → {"models":[{"name":"..."}]}
|
||||
try {
|
||||
List<String> m = fetchFromPath(baseUrl, "/api/tags", apiKey, "name");
|
||||
if (!m.isEmpty()) return m;
|
||||
} catch (Exception ignored) {}
|
||||
// OpenAI-compat: GET /v1/models → {"data":[{"id":"..."}]}
|
||||
try {
|
||||
List<String> m = fetchFromPath(baseUrl, "/v1/models", apiKey, "id");
|
||||
if (!m.isEmpty()) return m;
|
||||
} catch (Exception ignored) {}
|
||||
// Open-WebUI native: GET /api/models
|
||||
return fetchFromPath(baseUrl, "/api/models", apiKey, "id");
|
||||
}
|
||||
|
||||
private List<String> fetchFromPath(String baseUrl, String path, String apiKey, String field) throws Exception {
|
||||
URL url = URI.create(baseUrl + path).toURL();
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setRequestMethod("GET");
|
||||
conn.setConnectTimeout(5000);
|
||||
conn.setReadTimeout(5000);
|
||||
if (apiKey != null && !apiKey.isBlank()) {
|
||||
conn.setRequestProperty("Authorization", "Bearer " + apiKey);
|
||||
}
|
||||
int status = conn.getResponseCode();
|
||||
if (status != 200) throw new Exception("HTTP " + status + " from " + path);
|
||||
StringBuilder sb = new StringBuilder();
|
||||
try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream(), "utf-8"))) {
|
||||
String line;
|
||||
while ((line = br.readLine()) != null) sb.append(line);
|
||||
}
|
||||
conn.disconnect();
|
||||
|
||||
// Robust parser: handles both `"field":"value"` and `"field": "value"`
|
||||
List<String> result = new ArrayList<>();
|
||||
String body = sb.toString();
|
||||
String key = "\"" + field + "\"";
|
||||
int idx = 0;
|
||||
while ((idx = body.indexOf(key, idx)) >= 0) {
|
||||
idx += key.length();
|
||||
// skip whitespace and colon
|
||||
while (idx < body.length() && (body.charAt(idx) == ' ' || body.charAt(idx) == '\t' || body.charAt(idx) == ':')) idx++;
|
||||
if (idx < body.length() && body.charAt(idx) == '"') {
|
||||
int start = idx + 1;
|
||||
int end = body.indexOf('"', start);
|
||||
if (end > start) result.add(body.substring(start, end));
|
||||
idx = end + 1;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Rendering
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Override
|
||||
public void render(DrawContext context, int mouseX, int mouseY, float delta) {
|
||||
renderBackground(context);
|
||||
int cx = width / 2;
|
||||
int baseY = height / 2 - 100;
|
||||
|
||||
context.drawCenteredTextWithShadow(textRenderer, "§6AI Companion Setup", cx, baseY - 5, 0xFFFFFF);
|
||||
context.drawCenteredTextWithShadow(textRenderer, "§8Quick presets:", cx, baseY + 8, 0x888888);
|
||||
|
||||
context.drawTextWithShadow(textRenderer, "§7Server URL:", cx - 150, baseY + 38, 0xAAAAAA);
|
||||
context.drawTextWithShadow(textRenderer, "§7API Key §8(optional):", cx - 150, baseY + 73, 0xAAAAAA);
|
||||
context.drawTextWithShadow(textRenderer, "§7Model:", cx - 150, baseY + Y_MODEL_ROW, 0xAAAAAA);
|
||||
|
||||
if (fetchState == FetchState.LOADING) {
|
||||
context.drawTextWithShadow(textRenderer, "§7Fetching...", cx - 150, baseY + Y_MODEL_CONT + 4, 0x888888);
|
||||
} else if (fetchState == FetchState.ERROR && fetchError != null) {
|
||||
context.drawTextWithShadow(textRenderer, "§c" + fetchError, cx - 150, baseY + Y_MODEL_CONT + 4, 0xFF5555);
|
||||
}
|
||||
|
||||
{
|
||||
boolean hasList = fetchState == FetchState.SUCCESS && !fetchedModels.isEmpty();
|
||||
int listH = hasList ? Math.min(fetchedModels.size(), MAX_VISIBLE) * ITEM_H : 22;
|
||||
int afterModelY = baseY + Y_MODEL_CONT + listH + 4;
|
||||
if (advancedExpanded) {
|
||||
context.drawTextWithShadow(textRenderer, "§7API Path:", cx - 150, afterModelY + 8, 0xAAAAAA);
|
||||
}
|
||||
int modeY = advancedExpanded ? afterModelY + 45 : afterModelY + 18;
|
||||
context.drawTextWithShadow(textRenderer, "§7Mode:", cx - 155, modeY + 2, 0xAAAAAA);
|
||||
}
|
||||
|
||||
super.render(context, mouseX, mouseY, delta);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseScrolled(double mouseX, double mouseY, double amount) {
|
||||
if (fetchState == FetchState.SUCCESS && fetchedModels.size() > MAX_VISIBLE) {
|
||||
modelScrollOffset = Math.max(0, Math.min(
|
||||
fetchedModels.size() - MAX_VISIBLE,
|
||||
modelScrollOffset - (int) Math.signum(amount)
|
||||
));
|
||||
reinit();
|
||||
return true;
|
||||
}
|
||||
return super.mouseScrolled(mouseX, mouseY, amount);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean shouldPause() { return false; }
|
||||
}
|
||||
@@ -38,7 +38,7 @@ import net.minecraft.util.Identifier;
|
||||
public class Aicompanion2_0 implements ModInitializer {
|
||||
|
||||
public static final String MOD_ID = "aicompanion2_0";
|
||||
private static final String DEFAULT_API_BASE_URL = "https://ai.cametendo.org";
|
||||
private static final String DEFAULT_API_BASE_URL = "https://ai.example.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;
|
||||
@@ -53,6 +53,9 @@ public class Aicompanion2_0 implements ModInitializer {
|
||||
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 DELETE_CONFIG_PACKET_ID = new Identifier(MOD_ID, "delete_config");
|
||||
public static final Identifier MODEL_SELECT_PACKET_ID = new Identifier(MOD_ID, "model_select");
|
||||
public static final Identifier CHANGE_MODE_PACKET_ID = new Identifier(MOD_ID, "change_mode");
|
||||
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");
|
||||
|
||||
@@ -152,6 +155,54 @@ public class Aicompanion2_0 implements ModInitializer {
|
||||
}
|
||||
})
|
||||
)
|
||||
// /ai delete-config
|
||||
.then(CommandManager.literal("delete-config")
|
||||
.executes(ctx -> {
|
||||
var player = ctx.getSource().getPlayer();
|
||||
if (player != null) {
|
||||
if (!ServerPlayNetworking.canSend(player, DELETE_CONFIG_PACKET_ID)) {
|
||||
ctx.getSource().sendFeedback(
|
||||
() -> Text.literal("§c[AI] delete-config requires the AI Companion client mod."), false);
|
||||
return 0;
|
||||
}
|
||||
ServerPlayNetworking.send(player, DELETE_CONFIG_PACKET_ID, PacketByteBufs.create());
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
})
|
||||
)
|
||||
// /ai model
|
||||
.then(CommandManager.literal("model")
|
||||
.executes(ctx -> {
|
||||
var player = ctx.getSource().getPlayer();
|
||||
if (player != null) {
|
||||
if (!ServerPlayNetworking.canSend(player, MODEL_SELECT_PACKET_ID)) {
|
||||
ctx.getSource().sendFeedback(
|
||||
() -> Text.literal("§c[AI] model command requires the AI Companion client mod."), false);
|
||||
return 0;
|
||||
}
|
||||
ServerPlayNetworking.send(player, MODEL_SELECT_PACKET_ID, PacketByteBufs.create());
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
})
|
||||
)
|
||||
// /ai change-mode
|
||||
.then(CommandManager.literal("change-mode")
|
||||
.executes(ctx -> {
|
||||
var player = ctx.getSource().getPlayer();
|
||||
if (player != null) {
|
||||
if (!ServerPlayNetworking.canSend(player, CHANGE_MODE_PACKET_ID)) {
|
||||
ctx.getSource().sendFeedback(
|
||||
() -> Text.literal("§c[AI] change-mode requires the AI Companion client mod."), false);
|
||||
return 0;
|
||||
}
|
||||
ServerPlayNetworking.send(player, CHANGE_MODE_PACKET_ID, PacketByteBufs.create());
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
})
|
||||
)
|
||||
// /ai question <question>
|
||||
.then(CommandManager.literal("question")
|
||||
.then(CommandManager.argument("question", StringArgumentType.greedyString())
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "aicompanion2_0",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1+1.20.1",
|
||||
"name": "AI Companion 2.0",
|
||||
"description": "AI Chatbot powered by Ollama",
|
||||
"authors": ["Du"],
|
||||
|
||||
Reference in New Issue
Block a user