aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/ui/src/stores
diff options
context:
space:
mode:
Diffstat (limited to 'ui/src/stores')
-rw-r--r--ui/src/stores/assistant.svelte.ts166
-rw-r--r--ui/src/stores/game.svelte.ts25
-rw-r--r--ui/src/stores/instances.svelte.ts109
-rw-r--r--ui/src/stores/logs.svelte.ts8
-rw-r--r--ui/src/stores/settings.svelte.ts167
5 files changed, 467 insertions, 8 deletions
diff --git a/ui/src/stores/assistant.svelte.ts b/ui/src/stores/assistant.svelte.ts
new file mode 100644
index 0000000..a3f47ea
--- /dev/null
+++ b/ui/src/stores/assistant.svelte.ts
@@ -0,0 +1,166 @@
+import { invoke } from "@tauri-apps/api/core";
+import { listen, type UnlistenFn } from "@tauri-apps/api/event";
+
+export interface GenerationStats {
+ total_duration: number;
+ load_duration: number;
+ prompt_eval_count: number;
+ prompt_eval_duration: number;
+ eval_count: number;
+ eval_duration: number;
+}
+
+export interface Message {
+ role: "user" | "assistant" | "system";
+ content: string;
+ stats?: GenerationStats;
+}
+
+interface StreamChunk {
+ content: string;
+ done: boolean;
+ stats?: GenerationStats;
+}
+
+// Module-level state using $state
+let messages = $state<Message[]>([]);
+let isProcessing = $state(false);
+let isProviderHealthy = $state(false);
+let streamingContent = "";
+let initialized = false;
+let streamUnlisten: UnlistenFn | null = null;
+
+async function init() {
+ if (initialized) return;
+ initialized = true;
+ await checkHealth();
+}
+
+async function checkHealth() {
+ try {
+ isProviderHealthy = await invoke("assistant_check_health");
+ } catch (e) {
+ console.error("Failed to check provider health:", e);
+ isProviderHealthy = false;
+ }
+}
+
+function finishStreaming() {
+ isProcessing = false;
+ streamingContent = "";
+ if (streamUnlisten) {
+ streamUnlisten();
+ streamUnlisten = null;
+ }
+}
+
+async function sendMessage(
+ content: string,
+ isEnabled: boolean,
+ provider: string,
+ endpoint: string,
+) {
+ if (!content.trim()) return;
+ if (!isEnabled) {
+ messages = [
+ ...messages,
+ {
+ role: "assistant",
+ content: "Assistant is disabled. Enable it in Settings > AI Assistant.",
+ },
+ ];
+ return;
+ }
+
+ // Add user message
+ messages = [...messages, { role: "user", content }];
+ isProcessing = true;
+ streamingContent = "";
+
+ // Add empty assistant message for streaming
+ messages = [...messages, { role: "assistant", content: "" }];
+
+ try {
+ // Set up stream listener
+ streamUnlisten = await listen<StreamChunk>("assistant-stream", (event) => {
+ const chunk = event.payload;
+
+ if (chunk.content) {
+ streamingContent += chunk.content;
+ // Update the last message (assistant's response)
+ const lastIdx = messages.length - 1;
+ if (lastIdx >= 0 && messages[lastIdx].role === "assistant") {
+ messages[lastIdx] = {
+ ...messages[lastIdx],
+ content: streamingContent,
+ };
+ // Trigger reactivity
+ messages = [...messages];
+ }
+ }
+
+ if (chunk.done) {
+ if (chunk.stats) {
+ const lastIdx = messages.length - 1;
+ if (lastIdx >= 0 && messages[lastIdx].role === "assistant") {
+ messages[lastIdx] = {
+ ...messages[lastIdx],
+ stats: chunk.stats,
+ };
+ messages = [...messages];
+ }
+ }
+ finishStreaming();
+ }
+ });
+
+ // Start streaming chat
+ await invoke<string>("assistant_chat_stream", {
+ messages: messages.slice(0, -1), // Exclude the empty assistant message
+ });
+ } catch (e) {
+ console.error("Failed to send message:", e);
+ const errorMessage = e instanceof Error ? e.message : String(e);
+
+ let helpText = "";
+ if (provider === "ollama") {
+ helpText = `\n\nPlease ensure Ollama is running at ${endpoint}.`;
+ } else if (provider === "openai") {
+ helpText = "\n\nPlease check your OpenAI API key in Settings.";
+ }
+
+ // Update the last message with error
+ const lastIdx = messages.length - 1;
+ if (lastIdx >= 0 && messages[lastIdx].role === "assistant") {
+ messages[lastIdx] = {
+ role: "assistant",
+ content: `Error: ${errorMessage}${helpText}`,
+ };
+ messages = [...messages];
+ }
+
+ finishStreaming();
+ }
+}
+
+function clearHistory() {
+ messages = [];
+ streamingContent = "";
+}
+
+// Export as an object with getters for reactive access
+export const assistantState = {
+ get messages() {
+ return messages;
+ },
+ get isProcessing() {
+ return isProcessing;
+ },
+ get isProviderHealthy() {
+ return isProviderHealthy;
+ },
+ init,
+ checkHealth,
+ sendMessage,
+ clearHistory,
+};
diff --git a/ui/src/stores/game.svelte.ts b/ui/src/stores/game.svelte.ts
index 28b2db5..1e4119f 100644
--- a/ui/src/stores/game.svelte.ts
+++ b/ui/src/stores/game.svelte.ts
@@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core";
import type { Version } from "../types";
import { uiState } from "./ui.svelte";
import { authState } from "./auth.svelte";
+import { instancesState } from "./instances.svelte";
export class GameState {
versions = $state<Version[]>([]);
@@ -14,10 +15,8 @@ export class GameState {
async loadVersions() {
try {
this.versions = await invoke<Version[]>("get_versions");
- if (this.versions.length > 0) {
- const latest = this.versions.find((v) => v.type === "release");
- this.selectedVersion = latest ? latest.id : this.versions[0].id;
- }
+ // Don't auto-select version here - let BottomBar handle version selection
+ // based on installed versions only
} catch (e) {
console.error("Failed to fetch versions:", e);
uiState.setStatus("Error fetching versions: " + e);
@@ -36,10 +35,24 @@ export class GameState {
return;
}
+ if (!instancesState.activeInstanceId) {
+ alert("Please select an instance first!");
+ uiState.setView("instances");
+ return;
+ }
+
uiState.setStatus("Preparing to launch " + this.selectedVersion + "...");
- console.log("Invoking start_game for version:", this.selectedVersion);
+ console.log(
+ "Invoking start_game for version:",
+ this.selectedVersion,
+ "instance:",
+ instancesState.activeInstanceId,
+ );
try {
- const msg = await invoke<string>("start_game", { versionId: this.selectedVersion });
+ const msg = await invoke<string>("start_game", {
+ instanceId: instancesState.activeInstanceId,
+ versionId: this.selectedVersion,
+ });
console.log("Response:", msg);
uiState.setStatus(msg);
} catch (e) {
diff --git a/ui/src/stores/instances.svelte.ts b/ui/src/stores/instances.svelte.ts
new file mode 100644
index 0000000..f4ac4e9
--- /dev/null
+++ b/ui/src/stores/instances.svelte.ts
@@ -0,0 +1,109 @@
+import { invoke } from "@tauri-apps/api/core";
+import type { Instance } from "../types";
+import { uiState } from "./ui.svelte";
+
+export class InstancesState {
+ instances = $state<Instance[]>([]);
+ activeInstanceId = $state<string | null>(null);
+ get activeInstance(): Instance | null {
+ if (!this.activeInstanceId) return null;
+ return this.instances.find((i) => i.id === this.activeInstanceId) || null;
+ }
+
+ async loadInstances() {
+ try {
+ this.instances = await invoke<Instance[]>("list_instances");
+ const active = await invoke<Instance | null>("get_active_instance");
+ if (active) {
+ this.activeInstanceId = active.id;
+ } else if (this.instances.length > 0) {
+ // If no active instance but instances exist, set the first one as active
+ await this.setActiveInstance(this.instances[0].id);
+ }
+ } catch (e) {
+ console.error("Failed to load instances:", e);
+ uiState.setStatus("Error loading instances: " + e);
+ }
+ }
+
+ async createInstance(name: string): Promise<Instance | null> {
+ try {
+ const instance = await invoke<Instance>("create_instance", { name });
+ await this.loadInstances();
+ uiState.setStatus(`Instance "${name}" created successfully`);
+ return instance;
+ } catch (e) {
+ console.error("Failed to create instance:", e);
+ uiState.setStatus("Error creating instance: " + e);
+ return null;
+ }
+ }
+
+ async deleteInstance(id: string) {
+ try {
+ await invoke("delete_instance", { instanceId: id });
+ await this.loadInstances();
+ // If deleted instance was active, set another as active
+ if (this.activeInstanceId === id) {
+ if (this.instances.length > 0) {
+ await this.setActiveInstance(this.instances[0].id);
+ } else {
+ this.activeInstanceId = null;
+ }
+ }
+ uiState.setStatus("Instance deleted successfully");
+ } catch (e) {
+ console.error("Failed to delete instance:", e);
+ uiState.setStatus("Error deleting instance: " + e);
+ }
+ }
+
+ async updateInstance(instance: Instance) {
+ try {
+ await invoke("update_instance", { instance });
+ await this.loadInstances();
+ uiState.setStatus("Instance updated successfully");
+ } catch (e) {
+ console.error("Failed to update instance:", e);
+ uiState.setStatus("Error updating instance: " + e);
+ }
+ }
+
+ async setActiveInstance(id: string) {
+ try {
+ await invoke("set_active_instance", { instanceId: id });
+ this.activeInstanceId = id;
+ uiState.setStatus("Active instance changed");
+ } catch (e) {
+ console.error("Failed to set active instance:", e);
+ uiState.setStatus("Error setting active instance: " + e);
+ }
+ }
+
+ async duplicateInstance(id: string, newName: string): Promise<Instance | null> {
+ try {
+ const instance = await invoke<Instance>("duplicate_instance", {
+ instanceId: id,
+ newName,
+ });
+ await this.loadInstances();
+ uiState.setStatus(`Instance duplicated as "${newName}"`);
+ return instance;
+ } catch (e) {
+ console.error("Failed to duplicate instance:", e);
+ uiState.setStatus("Error duplicating instance: " + e);
+ return null;
+ }
+ }
+
+ async getInstance(id: string): Promise<Instance | null> {
+ try {
+ return await invoke<Instance>("get_instance", { instanceId: id });
+ } catch (e) {
+ console.error("Failed to get instance:", e);
+ return null;
+ }
+ }
+}
+
+export const instancesState = new InstancesState();
diff --git a/ui/src/stores/logs.svelte.ts b/ui/src/stores/logs.svelte.ts
index 5df9abc..c9d4acc 100644
--- a/ui/src/stores/logs.svelte.ts
+++ b/ui/src/stores/logs.svelte.ts
@@ -39,7 +39,6 @@ export class LogsState {
constructor() {
this.addLog("info", "Launcher", "Logs initialized");
- this.setupListeners();
}
addLog(level: LogEntry["level"], source: string, message: string) {
@@ -95,7 +94,12 @@ export class LogsState {
.join("\n");
}
- private async setupListeners() {
+ private initialized = false;
+
+ async init() {
+ if (this.initialized) return;
+ this.initialized = true;
+
// General Launcher Logs
await listen<string>("launcher-log", (e) => {
this.addLog("info", "Launcher", e.payload);
diff --git a/ui/src/stores/settings.svelte.ts b/ui/src/stores/settings.svelte.ts
index 12e4a1c..8a90736 100644
--- a/ui/src/stores/settings.svelte.ts
+++ b/ui/src/stores/settings.svelte.ts
@@ -8,6 +8,7 @@ import type {
JavaInstallation,
JavaReleaseInfo,
LauncherConfig,
+ ModelInfo,
PendingJavaDownload,
} from "../types";
import { uiState } from "./ui.svelte";
@@ -27,6 +28,20 @@ export class SettingsState {
custom_background_path: undefined,
log_upload_service: "paste.rs",
pastebin_api_key: undefined,
+ assistant: {
+ enabled: true,
+ llm_provider: "ollama",
+ ollama_endpoint: "http://localhost:11434",
+ ollama_model: "llama3",
+ openai_api_key: undefined,
+ openai_endpoint: "https://api.openai.com/v1",
+ openai_model: "gpt-3.5-turbo",
+ system_prompt:
+ "You are a helpful Minecraft expert assistant. You help players with game issues, mod installation, performance optimization, and gameplay tips. Analyze any game logs provided and give concise, actionable advice.",
+ response_language: "auto",
+ tts_enabled: false,
+ tts_provider: "disabled",
+ },
});
// Convert background path to proper asset URL
@@ -62,9 +77,58 @@ export class SettingsState {
// Pending downloads
pendingDownloads = $state<PendingJavaDownload[]>([]);
+ // AI Model lists
+ ollamaModels = $state<ModelInfo[]>([]);
+ openaiModels = $state<ModelInfo[]>([]);
+ isLoadingOllamaModels = $state(false);
+ isLoadingOpenaiModels = $state(false);
+ ollamaModelsError = $state("");
+ openaiModelsError = $state("");
+
+ // Config Editor state
+ showConfigEditor = $state(false);
+ rawConfigContent = $state("");
+ configFilePath = $state("");
+ configEditorError = $state("");
+
// Event listener cleanup
private progressUnlisten: UnlistenFn | null = null;
+ async openConfigEditor() {
+ this.configEditorError = "";
+ try {
+ const path = await invoke<string>("get_config_path");
+ const content = await invoke<string>("read_raw_config");
+ this.configFilePath = path;
+ this.rawConfigContent = content;
+ this.showConfigEditor = true;
+ } catch (e) {
+ console.error("Failed to open config editor:", e);
+ uiState.setStatus(`Failed to open config: ${e}`);
+ }
+ }
+
+ async saveRawConfig(content: string, closeAfterSave = true) {
+ try {
+ await invoke("save_raw_config", { content });
+ // Reload settings to ensure UI is in sync
+ await this.loadSettings();
+ if (closeAfterSave) {
+ this.showConfigEditor = false;
+ }
+ uiState.setStatus("Configuration saved successfully!");
+ } catch (e) {
+ console.error("Failed to save config:", e);
+ this.configEditorError = String(e);
+ }
+ }
+
+ closeConfigEditor() {
+ this.showConfigEditor = false;
+ this.rawConfigContent = "";
+ this.configEditorError = "";
+ }
+
// Computed: filtered releases based on selection
get filteredReleases(): JavaReleaseInfo[] {
if (!this.javaCatalog) return [];
@@ -389,6 +453,109 @@ export class SettingsState {
get availableJavaVersions(): number[] {
return this.availableMajorVersions;
}
+
+ // AI Model loading methods
+ async loadOllamaModels() {
+ this.isLoadingOllamaModels = true;
+ this.ollamaModelsError = "";
+
+ try {
+ const models = await invoke<ModelInfo[]>("list_ollama_models", {
+ endpoint: this.settings.assistant.ollama_endpoint,
+ });
+ this.ollamaModels = models;
+
+ // If no model is selected or selected model isn't available, select the first one
+ if (models.length > 0) {
+ const currentModel = this.settings.assistant.ollama_model;
+ const modelExists = models.some((m) => m.id === currentModel);
+ if (!modelExists) {
+ this.settings.assistant.ollama_model = models[0].id;
+ }
+ }
+ } catch (e) {
+ console.error("Failed to load Ollama models:", e);
+ this.ollamaModelsError = String(e);
+ this.ollamaModels = [];
+ } finally {
+ this.isLoadingOllamaModels = false;
+ }
+ }
+
+ async loadOpenaiModels() {
+ if (!this.settings.assistant.openai_api_key) {
+ this.openaiModelsError = "API key required";
+ this.openaiModels = [];
+ return;
+ }
+
+ this.isLoadingOpenaiModels = true;
+ this.openaiModelsError = "";
+
+ try {
+ const models = await invoke<ModelInfo[]>("list_openai_models");
+ this.openaiModels = models;
+
+ // If no model is selected or selected model isn't available, select the first one
+ if (models.length > 0) {
+ const currentModel = this.settings.assistant.openai_model;
+ const modelExists = models.some((m) => m.id === currentModel);
+ if (!modelExists) {
+ this.settings.assistant.openai_model = models[0].id;
+ }
+ }
+ } catch (e) {
+ console.error("Failed to load OpenAI models:", e);
+ this.openaiModelsError = String(e);
+ this.openaiModels = [];
+ } finally {
+ this.isLoadingOpenaiModels = false;
+ }
+ }
+
+ // Computed: get model options for current provider
+ get currentModelOptions(): { value: string; label: string; details?: string }[] {
+ const provider = this.settings.assistant.llm_provider;
+
+ if (provider === "ollama") {
+ if (this.ollamaModels.length === 0) {
+ // Return fallback options if no models loaded
+ return [
+ { value: "llama3", label: "Llama 3" },
+ { value: "llama3.1", label: "Llama 3.1" },
+ { value: "llama3.2", label: "Llama 3.2" },
+ { value: "mistral", label: "Mistral" },
+ { value: "gemma2", label: "Gemma 2" },
+ { value: "qwen2.5", label: "Qwen 2.5" },
+ { value: "phi3", label: "Phi-3" },
+ { value: "codellama", label: "Code Llama" },
+ ];
+ }
+ return this.ollamaModels.map((m) => ({
+ value: m.id,
+ label: m.name,
+ details: m.size ? `${m.size}${m.details ? ` - ${m.details}` : ""}` : m.details,
+ }));
+ } else if (provider === "openai") {
+ if (this.openaiModels.length === 0) {
+ // Return fallback options if no models loaded
+ return [
+ { value: "gpt-4o", label: "GPT-4o" },
+ { value: "gpt-4o-mini", label: "GPT-4o Mini" },
+ { value: "gpt-4-turbo", label: "GPT-4 Turbo" },
+ { value: "gpt-4", label: "GPT-4" },
+ { value: "gpt-3.5-turbo", label: "GPT-3.5 Turbo" },
+ ];
+ }
+ return this.openaiModels.map((m) => ({
+ value: m.id,
+ label: m.name,
+ details: m.details,
+ }));
+ }
+
+ return [];
+ }
}
export const settingsState = new SettingsState();