aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/ui/src
diff options
context:
space:
mode:
Diffstat (limited to 'ui/src')
-rw-r--r--ui/src/App.svelte26
-rw-r--r--ui/src/assets/svelte.svg2
-rw-r--r--ui/src/components/AssistantView.svelte436
-rw-r--r--ui/src/components/BottomBar.svelte66
-rw-r--r--ui/src/components/ConfigEditorModal.svelte369
-rw-r--r--ui/src/components/CustomSelect.svelte43
-rw-r--r--ui/src/components/InstancesView.svelte331
-rw-r--r--ui/src/components/ModLoaderSelector.svelte56
-rw-r--r--ui/src/components/SettingsView.svelte386
-rw-r--r--ui/src/components/Sidebar.svelte6
-rw-r--r--ui/src/components/VersionsView.svelte336
-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
-rw-r--r--ui/src/types/index.ts45
17 files changed, 2469 insertions, 108 deletions
diff --git a/ui/src/App.svelte b/ui/src/App.svelte
index 760a15f..127bbea 100644
--- a/ui/src/App.svelte
+++ b/ui/src/App.svelte
@@ -10,14 +10,18 @@
import LoginModal from "./components/LoginModal.svelte";
import ParticleBackground from "./components/ParticleBackground.svelte";
import SettingsView from "./components/SettingsView.svelte";
+ import AssistantView from "./components/AssistantView.svelte";
+ import InstancesView from "./components/InstancesView.svelte";
import Sidebar from "./components/Sidebar.svelte";
import StatusToast from "./components/StatusToast.svelte";
import VersionsView from "./components/VersionsView.svelte";
// Stores
import { authState } from "./stores/auth.svelte";
import { gameState } from "./stores/game.svelte";
+ import { instancesState } from "./stores/instances.svelte";
import { settingsState } from "./stores/settings.svelte";
import { uiState } from "./stores/ui.svelte";
+ import { logsState } from "./stores/logs.svelte";
import { convertFileSrc } from "@tauri-apps/api/core";
let mouseX = $state(0);
@@ -29,24 +33,20 @@
}
onMount(async () => {
+ // ENFORCE DARK MODE: Always add 'dark' class and attribute
+ document.documentElement.classList.add('dark');
+ document.documentElement.setAttribute('data-theme', 'dark');
+ document.documentElement.classList.remove('light');
+
authState.checkAccount();
await settingsState.loadSettings();
+ logsState.init();
await settingsState.detectJava();
+ await instancesState.loadInstances();
gameState.loadVersions();
getVersion().then((v) => (uiState.appVersion = v));
window.addEventListener("mousemove", handleMouseMove);
});
-
- $effect(() => {
- // ENFORCE DARK MODE: Always add 'dark' class and attribute
- // This combined with the @variant dark in app.css ensures dark mode is always active
- // regardless of system preference settings.
- document.documentElement.classList.add('dark');
- document.documentElement.setAttribute('data-theme', 'dark');
-
- // Ensure 'light' class is never present
- document.documentElement.classList.remove('light');
- });
onDestroy(() => {
if (typeof window !== 'undefined')
@@ -116,10 +116,14 @@
<div class="flex-1 relative overflow-hidden">
{#if uiState.currentView === "home"}
<HomeView mouseX={mouseX} mouseY={mouseY} />
+ {:else if uiState.currentView === "instances"}
+ <InstancesView />
{:else if uiState.currentView === "versions"}
<VersionsView />
{:else if uiState.currentView === "settings"}
<SettingsView />
+ {:else if uiState.currentView === "guide"}
+ <AssistantView />
{/if}
</div>
diff --git a/ui/src/assets/svelte.svg b/ui/src/assets/svelte.svg
index c5e0848..8c056ce 100644
--- a/ui/src/assets/svelte.svg
+++ b/ui/src/assets/svelte.svg
@@ -1 +1 @@
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg> \ No newline at end of file
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>
diff --git a/ui/src/components/AssistantView.svelte b/ui/src/components/AssistantView.svelte
new file mode 100644
index 0000000..54509a5
--- /dev/null
+++ b/ui/src/components/AssistantView.svelte
@@ -0,0 +1,436 @@
+<script lang="ts">
+ import { assistantState } from '../stores/assistant.svelte';
+ import { settingsState } from '../stores/settings.svelte';
+ import { Send, Bot, RefreshCw, Trash2, AlertTriangle, Settings, Brain, ChevronDown } from 'lucide-svelte';
+ import { uiState } from '../stores/ui.svelte';
+ import { marked } from 'marked';
+ import { onMount } from 'svelte';
+
+ let input = $state('');
+ let messagesContainer: HTMLDivElement | undefined = undefined;
+
+ function parseMessageContent(content: string) {
+ if (!content) return { thinking: null, content: '', isThinking: false };
+
+ // Support both <thinking> and <think> (DeepSeek uses <think>)
+ let startTag = '<thinking>';
+ let endTag = '</thinking>';
+ let startIndex = content.indexOf(startTag);
+
+ if (startIndex === -1) {
+ startTag = '<think>';
+ endTag = '</think>';
+ startIndex = content.indexOf(startTag);
+ }
+
+ // Also check for encoded tags if they weren't decoded properly
+ if (startIndex === -1) {
+ startTag = '\u003cthink\u003e';
+ endTag = '\u003c/think\u003e';
+ startIndex = content.indexOf(startTag);
+ }
+
+ if (startIndex !== -1) {
+ const endIndex = content.indexOf(endTag, startIndex);
+
+ if (endIndex !== -1) {
+ // Completed thinking block
+ // We extract the thinking part and keep the rest (before and after)
+ const before = content.substring(0, startIndex);
+ const thinking = content.substring(startIndex + startTag.length, endIndex).trim();
+ const after = content.substring(endIndex + endTag.length);
+
+ return {
+ thinking,
+ content: (before + after).trim(),
+ isThinking: false
+ };
+ } else {
+ // Incomplete thinking block (still streaming)
+ const before = content.substring(0, startIndex);
+ const thinking = content.substring(startIndex + startTag.length).trim();
+
+ return {
+ thinking,
+ content: before.trim(),
+ isThinking: true
+ };
+ }
+ }
+
+ return { thinking: null, content, isThinking: false };
+ }
+
+ function renderMarkdown(content: string): string {
+ if (!content) return '';
+ try {
+ // marked.parse returns string synchronously when async is false (default)
+ return marked(content, { breaks: true, gfm: true }) as string;
+ } catch {
+ return content;
+ }
+ }
+
+ function scrollToBottom() {
+ if (messagesContainer) {
+ setTimeout(() => {
+ if (messagesContainer) {
+ messagesContainer.scrollTop = messagesContainer.scrollHeight;
+ }
+ }, 0);
+ }
+ }
+
+ onMount(() => {
+ assistantState.init();
+ });
+
+ // Scroll to bottom when messages change
+ $effect(() => {
+ // Access reactive state
+ const _len = assistantState.messages.length;
+ const _processing = assistantState.isProcessing;
+ // Scroll on next tick
+ if (_len > 0 || _processing) {
+ scrollToBottom();
+ }
+ });
+
+ async function handleSubmit() {
+ if (!input.trim() || assistantState.isProcessing) return;
+ const text = input;
+ input = '';
+ const provider = settingsState.settings.assistant.llm_provider;
+ const endpoint = provider === 'ollama'
+ ? settingsState.settings.assistant.ollama_endpoint
+ : settingsState.settings.assistant.openai_endpoint;
+ await assistantState.sendMessage(
+ text,
+ settingsState.settings.assistant.enabled,
+ provider,
+ endpoint
+ );
+ }
+
+ function handleKeydown(e: KeyboardEvent) {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ handleSubmit();
+ }
+ }
+
+ function getProviderName(): string {
+ const provider = settingsState.settings.assistant.llm_provider;
+ if (provider === 'ollama') {
+ return `Ollama (${settingsState.settings.assistant.ollama_model})`;
+ } else if (provider === 'openai') {
+ return `OpenAI (${settingsState.settings.assistant.openai_model})`;
+ }
+ return provider;
+ }
+
+ function getProviderHelpText(): string {
+ const provider = settingsState.settings.assistant.llm_provider;
+ if (provider === 'ollama') {
+ return `Please ensure Ollama is installed and running at ${settingsState.settings.assistant.ollama_endpoint}.`;
+ } else if (provider === 'openai') {
+ return "Please check your OpenAI API key in Settings > AI Assistant.";
+ }
+ return "";
+ }
+</script>
+
+<div class="h-full w-full flex flex-col gap-4 p-4 lg:p-8 animate-in fade-in zoom-in-95 duration-300">
+ <div class="flex items-center justify-between mb-2">
+ <div class="flex items-center gap-3">
+ <div class="p-2 bg-indigo-500/20 rounded-lg text-indigo-400">
+ <Bot size={24} />
+ </div>
+ <div>
+ <h2 class="text-2xl font-bold">Game Assistant</h2>
+ <p class="text-zinc-400 text-sm">Powered by {getProviderName()}</p>
+ </div>
+ </div>
+
+ <div class="flex items-center gap-2">
+ {#if !settingsState.settings.assistant.enabled}
+ <div class="flex items-center gap-2 px-3 py-1.5 bg-zinc-500/10 text-zinc-400 rounded-full text-xs font-medium border border-zinc-500/20">
+ <AlertTriangle size={14} />
+ <span>Disabled</span>
+ </div>
+ {:else if !assistantState.isProviderHealthy}
+ <div class="flex items-center gap-2 px-3 py-1.5 bg-red-500/10 text-red-400 rounded-full text-xs font-medium border border-red-500/20">
+ <AlertTriangle size={14} />
+ <span>Offline</span>
+ </div>
+ {:else}
+ <div class="flex items-center gap-2 px-3 py-1.5 bg-emerald-500/10 text-emerald-400 rounded-full text-xs font-medium border border-emerald-500/20">
+ <div class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></div>
+ <span>Online</span>
+ </div>
+ {/if}
+
+ <button
+ onclick={() => assistantState.checkHealth()}
+ class="p-2 hover:bg-white/5 rounded-lg text-zinc-400 hover:text-white transition-colors"
+ title="Check Connection"
+ >
+ <RefreshCw size={18} class={assistantState.isProcessing ? "animate-spin" : ""} />
+ </button>
+
+ <button
+ onclick={() => assistantState.clearHistory()}
+ class="p-2 hover:bg-white/5 rounded-lg text-zinc-400 hover:text-white transition-colors"
+ title="Clear History"
+ >
+ <Trash2 size={18} />
+ </button>
+
+ <button
+ onclick={() => uiState.setView('settings')}
+ class="p-2 hover:bg-white/5 rounded-lg text-zinc-400 hover:text-white transition-colors"
+ title="Settings"
+ >
+ <Settings size={18} />
+ </button>
+ </div>
+ </div>
+
+ <!-- Chat Area -->
+ <div class="flex-1 bg-black/20 border border-white/5 rounded-xl overflow-hidden flex flex-col relative">
+ {#if assistantState.messages.length === 0}
+ <div class="absolute inset-0 flex flex-col items-center justify-center text-zinc-500 gap-4 p-8 text-center">
+ <Bot size={48} class="opacity-20" />
+ <div class="max-w-md">
+ <p class="text-lg font-medium text-zinc-300 mb-2">How can I help you today?</p>
+ <p class="text-sm opacity-70">I can analyze your game logs, diagnose crashes, or explain mod features.</p>
+ </div>
+ {#if !settingsState.settings.assistant.enabled}
+ <div class="bg-zinc-500/10 border border-zinc-500/20 rounded-lg p-4 text-sm text-zinc-400 mt-4 max-w-sm">
+ Assistant is disabled. Enable it in <button onclick={() => uiState.setView('settings')} class="text-indigo-400 hover:underline">Settings > AI Assistant</button>.
+ </div>
+ {:else if !assistantState.isProviderHealthy}
+ <div class="bg-red-500/10 border border-red-500/20 rounded-lg p-4 text-sm text-red-400 mt-4 max-w-sm">
+ {getProviderHelpText()}
+ </div>
+ {/if}
+ </div>
+ {/if}
+
+ <div
+ bind:this={messagesContainer}
+ class="flex-1 overflow-y-auto p-4 space-y-4 scroll-smooth"
+ >
+ {#each assistantState.messages as msg, idx}
+ <div class="flex gap-3 {msg.role === 'user' ? 'justify-end' : 'justify-start'}">
+ {#if msg.role === 'assistant'}
+ <div class="w-8 h-8 rounded-full bg-indigo-500/20 flex items-center justify-center text-indigo-400 shrink-0 mt-1">
+ <Bot size={16} />
+ </div>
+ {/if}
+
+ <div class="max-w-[80%] p-3 rounded-2xl {msg.role === 'user' ? 'bg-indigo-600 text-white rounded-tr-none' : 'bg-zinc-800/50 text-zinc-200 rounded-tl-none border border-white/5'}">
+ {#if msg.role === 'user'}
+ <div class="break-words whitespace-pre-wrap">
+ {msg.content}
+ </div>
+ {:else}
+ {@const parsed = parseMessageContent(msg.content)}
+
+ <!-- Thinking Block -->
+ {#if parsed.thinking}
+ <div class="mb-3 max-w-full overflow-hidden">
+ <details class="group" open={parsed.isThinking}>
+ <summary class="list-none cursor-pointer flex items-center gap-2 text-zinc-500 hover:text-zinc-300 transition-colors text-xs font-medium select-none bg-black/20 p-2 rounded-lg border border-white/5 w-fit mb-2 outline-none">
+ <Brain size={14} />
+ <span>Thinking Process</span>
+ <ChevronDown size={14} class="transition-transform duration-200 group-open:rotate-180" />
+ </summary>
+ <div class="pl-3 border-l-2 border-zinc-700 text-zinc-500 text-xs italic leading-relaxed whitespace-pre-wrap font-mono max-h-96 overflow-y-auto custom-scrollbar bg-black/10 p-2 rounded-r-md">
+ {parsed.thinking}
+ {#if parsed.isThinking}
+ <span class="inline-block w-1.5 h-3 bg-zinc-500 ml-1 animate-pulse align-middle"></span>
+ {/if}
+ </div>
+ </details>
+ </div>
+ {/if}
+
+ <!-- Markdown rendered content for assistant -->
+ <div class="markdown-content prose prose-invert prose-sm max-w-none">
+ {#if parsed.content}
+ {@html renderMarkdown(parsed.content)}
+ {:else if assistantState.isProcessing && idx === assistantState.messages.length - 1 && !parsed.isThinking}
+ <span class="inline-flex items-center gap-1">
+ <span class="w-2 h-2 bg-zinc-400 rounded-full animate-pulse"></span>
+ <span class="w-2 h-2 bg-zinc-400 rounded-full animate-pulse" style="animation-delay: 0.2s"></span>
+ <span class="w-2 h-2 bg-zinc-400 rounded-full animate-pulse" style="animation-delay: 0.4s"></span>
+ </span>
+ {/if}
+ </div>
+
+ <!-- Generation Stats -->
+ {#if msg.stats}
+ <div class="mt-3 pt-3 border-t border-white/5 text-[10px] text-zinc-500 font-mono flex flex-wrap gap-x-4 gap-y-1 opacity-70 hover:opacity-100 transition-opacity select-none">
+ <div class="flex gap-1" title="Tokens generated">
+ <span>Eval:</span>
+ <span class="text-zinc-400">{msg.stats.eval_count} tokens</span>
+ </div>
+ <div class="flex gap-1" title="Total duration">
+ <span>Time:</span>
+ <span class="text-zinc-400">{(msg.stats.total_duration / 1e9).toFixed(2)}s</span>
+ </div>
+ {#if msg.stats.eval_duration > 0}
+ <div class="flex gap-1" title="Generation speed">
+ <span>Speed:</span>
+ <span class="text-zinc-400">{(msg.stats.eval_count / (msg.stats.eval_duration / 1e9)).toFixed(1)} t/s</span>
+ </div>
+ {/if}
+ </div>
+ {/if}
+ {/if}
+ </div>
+ </div>
+ {/each}
+ </div>
+
+ <!-- Input Area -->
+ <div class="p-4 bg-zinc-900/50 border-t border-white/5">
+ <div class="relative">
+ <textarea
+ bind:value={input}
+ onkeydown={handleKeydown}
+ placeholder={settingsState.settings.assistant.enabled ? "Ask about your game..." : "Assistant is disabled..."}
+ class="w-full bg-black/20 border border-white/10 rounded-xl py-3 pl-4 pr-12 focus:outline-none focus:border-indigo-500/50 focus:ring-1 focus:ring-indigo-500/50 resize-none h-[52px] max-h-32 transition-all text-white disabled:opacity-50"
+ disabled={assistantState.isProcessing || !settingsState.settings.assistant.enabled}
+ ></textarea>
+
+ <button
+ onclick={handleSubmit}
+ disabled={!input.trim() || assistantState.isProcessing || !settingsState.settings.assistant.enabled}
+ class="absolute right-2 top-2 p-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:hover:bg-indigo-600 text-white rounded-lg transition-colors"
+ >
+ <Send size={16} />
+ </button>
+ </div>
+ </div>
+ </div>
+</div>
+
+<style>
+ /* Markdown content styles */
+ .markdown-content :global(p) {
+ margin-bottom: 0.5rem;
+ }
+
+ .markdown-content :global(p:last-child) {
+ margin-bottom: 0;
+ }
+
+ .markdown-content :global(pre) {
+ background-color: rgba(0, 0, 0, 0.4);
+ border-radius: 0.5rem;
+ padding: 0.75rem;
+ overflow-x: auto;
+ margin: 0.5rem 0;
+ }
+
+ .markdown-content :global(code) {
+ font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
+ font-size: 0.85em;
+ }
+
+ .markdown-content :global(pre code) {
+ background: none;
+ padding: 0;
+ }
+
+ .markdown-content :global(:not(pre) > code) {
+ background-color: rgba(0, 0, 0, 0.3);
+ padding: 0.15rem 0.4rem;
+ border-radius: 0.25rem;
+ }
+
+ .markdown-content :global(ul),
+ .markdown-content :global(ol) {
+ margin: 0.5rem 0;
+ padding-left: 1.5rem;
+ }
+
+ .markdown-content :global(li) {
+ margin: 0.25rem 0;
+ }
+
+ .markdown-content :global(blockquote) {
+ border-left: 3px solid rgba(99, 102, 241, 0.5);
+ padding-left: 1rem;
+ margin: 0.5rem 0;
+ color: rgba(255, 255, 255, 0.7);
+ }
+
+ .markdown-content :global(h1),
+ .markdown-content :global(h2),
+ .markdown-content :global(h3),
+ .markdown-content :global(h4) {
+ font-weight: 600;
+ margin: 0.75rem 0 0.5rem 0;
+ }
+
+ .markdown-content :global(h1) {
+ font-size: 1.25rem;
+ }
+
+ .markdown-content :global(h2) {
+ font-size: 1.125rem;
+ }
+
+ .markdown-content :global(h3) {
+ font-size: 1rem;
+ }
+
+ .markdown-content :global(a) {
+ color: rgb(129, 140, 248);
+ text-decoration: underline;
+ }
+
+ .markdown-content :global(a:hover) {
+ color: rgb(165, 180, 252);
+ }
+
+ .markdown-content :global(table) {
+ border-collapse: collapse;
+ margin: 0.5rem 0;
+ width: 100%;
+ }
+
+ .markdown-content :global(th),
+ .markdown-content :global(td) {
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ padding: 0.5rem;
+ text-align: left;
+ }
+
+ .markdown-content :global(th) {
+ background-color: rgba(0, 0, 0, 0.3);
+ font-weight: 600;
+ }
+
+ .markdown-content :global(hr) {
+ border: none;
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
+ margin: 1rem 0;
+ }
+
+ .markdown-content :global(img) {
+ max-width: 100%;
+ border-radius: 0.5rem;
+ }
+
+ .markdown-content :global(strong) {
+ font-weight: 600;
+ }
+
+ .markdown-content :global(em) {
+ font-style: italic;
+ }
+</style>
diff --git a/ui/src/components/BottomBar.svelte b/ui/src/components/BottomBar.svelte
index b7bbf71..19cf35d 100644
--- a/ui/src/components/BottomBar.svelte
+++ b/ui/src/components/BottomBar.svelte
@@ -4,7 +4,8 @@
import { authState } from "../stores/auth.svelte";
import { gameState } from "../stores/game.svelte";
import { uiState } from "../stores/ui.svelte";
- import { Terminal, ChevronDown, Play, User, Check, RefreshCw } from 'lucide-svelte';
+ import { instancesState } from "../stores/instances.svelte";
+ import { Terminal, ChevronDown, Play, User, Check } from 'lucide-svelte';
interface InstalledVersion {
id: string;
@@ -16,29 +17,44 @@
let installedVersions = $state<InstalledVersion[]>([]);
let isLoadingVersions = $state(true);
let downloadCompleteUnlisten: UnlistenFn | null = null;
+ let versionDeletedUnlisten: UnlistenFn | null = null;
// Load installed versions on mount
$effect(() => {
loadInstalledVersions();
- setupDownloadListener();
+ setupEventListeners();
return () => {
if (downloadCompleteUnlisten) {
downloadCompleteUnlisten();
}
+ if (versionDeletedUnlisten) {
+ versionDeletedUnlisten();
+ }
};
});
- async function setupDownloadListener() {
+ async function setupEventListeners() {
// Refresh list when a download completes
downloadCompleteUnlisten = await listen("download-complete", () => {
loadInstalledVersions();
});
+ // Refresh list when a version is deleted
+ versionDeletedUnlisten = await listen("version-deleted", () => {
+ loadInstalledVersions();
+ });
}
async function loadInstalledVersions() {
+ if (!instancesState.activeInstanceId) {
+ installedVersions = [];
+ isLoadingVersions = false;
+ return;
+ }
isLoadingVersions = true;
try {
- installedVersions = await invoke<InstalledVersion[]>("list_installed_versions");
+ installedVersions = await invoke<InstalledVersion[]>("list_installed_versions", {
+ instanceId: instancesState.activeInstanceId,
+ });
// If no version is selected but we have installed versions, select the first one
if (!gameState.selectedVersion && installedVersions.length > 0) {
gameState.selectedVersion = installedVersions[0].id;
@@ -160,18 +176,7 @@
<div class="flex flex-col items-end mr-2">
<!-- Custom Version Dropdown -->
<div class="relative" bind:this={dropdownRef}>
- <div class="flex items-center gap-2">
- <button
- type="button"
- onclick={() => loadInstalledVersions()}
- class="p-2.5 dark:bg-zinc-900 bg-zinc-50 border dark:border-zinc-700 border-zinc-300 rounded-md
- dark:text-zinc-500 text-zinc-400 dark:hover:text-white hover:text-black
- dark:hover:border-zinc-600 hover:border-zinc-400 transition-colors"
- title="Refresh installed versions"
- >
- <RefreshCw size={14} class={isLoadingVersions ? 'animate-spin' : ''} />
- </button>
- <button
+ <button
type="button"
onclick={() => isVersionDropdownOpen = !isVersionDropdownOpen}
disabled={installedVersions.length === 0 && !isLoadingVersions}
@@ -183,21 +188,20 @@
transition-colors cursor-pointer outline-none
disabled:opacity-50 disabled:cursor-not-allowed"
>
- <span class="truncate">
- {#if isLoadingVersions}
- Loading...
- {:else if installedVersions.length === 0}
- No versions installed
- {:else}
- {gameState.selectedVersion || "Select version"}
- {/if}
- </span>
- <ChevronDown
- size={14}
- class="shrink-0 dark:text-zinc-500 text-zinc-400 transition-transform duration-200 {isVersionDropdownOpen ? 'rotate-180' : ''}"
- />
- </button>
- </div>
+ <span class="truncate">
+ {#if isLoadingVersions}
+ Loading...
+ {:else if installedVersions.length === 0}
+ No versions installed
+ {:else}
+ {gameState.selectedVersion || "Select version"}
+ {/if}
+ </span>
+ <ChevronDown
+ size={14}
+ class="shrink-0 dark:text-zinc-500 text-zinc-400 transition-transform duration-200 {isVersionDropdownOpen ? 'rotate-180' : ''}"
+ />
+ </button>
{#if isVersionDropdownOpen && installedVersions.length > 0}
<div
diff --git a/ui/src/components/ConfigEditorModal.svelte b/ui/src/components/ConfigEditorModal.svelte
new file mode 100644
index 0000000..dd866ee
--- /dev/null
+++ b/ui/src/components/ConfigEditorModal.svelte
@@ -0,0 +1,369 @@
+<script lang="ts">
+ import { settingsState } from "../stores/settings.svelte";
+ import { Save, X, FileJson, AlertCircle, Undo, Redo, Settings } from "lucide-svelte";
+ import Prism from 'prismjs';
+ import 'prismjs/components/prism-json';
+ import 'prismjs/themes/prism-tomorrow.css';
+
+ let content = $state(settingsState.rawConfigContent);
+ let isSaving = $state(false);
+ let localError = $state("");
+
+ let textareaRef: HTMLTextAreaElement | undefined = $state();
+ let preRef: HTMLPreElement | undefined = $state();
+ let lineNumbersRef: HTMLDivElement | undefined = $state();
+
+ // Textarea attributes that TypeScript doesn't recognize but are valid HTML
+ const textareaAttrs = {
+ autocorrect: "off",
+ autocapitalize: "off"
+ } as Record<string, string>;
+
+ // History State
+ let history = $state([settingsState.rawConfigContent]);
+ let historyIndex = $state(0);
+ let debounceTimer: ReturnType<typeof setTimeout> | undefined;
+
+ // Editor Settings
+ let showLineNumbers = $state(localStorage.getItem('editor_showLineNumbers') !== 'false');
+ let showStatusBar = $state(localStorage.getItem('editor_showStatusBar') !== 'false');
+ let showSettings = $state(false);
+
+ // Cursor Status
+ let cursorLine = $state(1);
+ let cursorCol = $state(1);
+
+ let lines = $derived(content.split('\n'));
+
+ $effect(() => {
+ localStorage.setItem('editor_showLineNumbers', String(showLineNumbers));
+ localStorage.setItem('editor_showStatusBar', String(showStatusBar));
+ });
+
+ // Cleanup timer on destroy
+ $effect(() => {
+ return () => {
+ if (debounceTimer) clearTimeout(debounceTimer);
+ };
+ });
+
+ // Initial validation
+ $effect(() => {
+ validate(content);
+ });
+
+ function validate(text: string) {
+ try {
+ JSON.parse(text);
+ localError = "";
+ } catch (e: any) {
+ localError = e.message;
+ }
+ }
+
+ function pushHistory(newContent: string, immediate = false) {
+ if (debounceTimer) clearTimeout(debounceTimer);
+
+ const commit = () => {
+ if (newContent === history[historyIndex]) return;
+ const next = history.slice(0, historyIndex + 1);
+ next.push(newContent);
+ history = next;
+ historyIndex = next.length - 1;
+ };
+
+ if (immediate) {
+ commit();
+ } else {
+ debounceTimer = setTimeout(commit, 500);
+ }
+ }
+
+ function handleUndo() {
+ if (historyIndex > 0) {
+ historyIndex--;
+ content = history[historyIndex];
+ validate(content);
+ }
+ }
+
+ function handleRedo() {
+ if (historyIndex < history.length - 1) {
+ historyIndex++;
+ content = history[historyIndex];
+ validate(content);
+ }
+ }
+
+ function updateCursor() {
+ if (!textareaRef) return;
+ const pos = textareaRef.selectionStart;
+ const text = textareaRef.value.substring(0, pos);
+ const lines = text.split('\n');
+ cursorLine = lines.length;
+ cursorCol = lines[lines.length - 1].length + 1;
+ }
+
+ function handleInput(e: Event) {
+ const target = e.target as HTMLTextAreaElement;
+ content = target.value;
+ validate(content);
+ pushHistory(content);
+ updateCursor();
+ }
+
+ function handleScroll() {
+ if (textareaRef) {
+ if (preRef) {
+ preRef.scrollTop = textareaRef.scrollTop;
+ preRef.scrollLeft = textareaRef.scrollLeft;
+ }
+ if (lineNumbersRef) {
+ lineNumbersRef.scrollTop = textareaRef.scrollTop;
+ }
+ }
+ }
+
+ let highlightedCode = $derived(
+ Prism.highlight(content, Prism.languages.json, 'json') + '\n'
+ );
+
+ async function handleSave(close = false) {
+ if (localError) return;
+ isSaving = true;
+ await settingsState.saveRawConfig(content, close);
+ isSaving = false;
+ }
+
+ function handleKeydown(e: KeyboardEvent) {
+ // Save
+ if (e.key === 's' && (e.ctrlKey || e.metaKey)) {
+ e.preventDefault();
+ handleSave(false); // Keep open on shortcut save
+ }
+ // Undo
+ else if (e.key === 'z' && (e.ctrlKey || e.metaKey) && !e.shiftKey) {
+ e.preventDefault();
+ handleUndo();
+ }
+ // Redo (Ctrl+Shift+Z or Ctrl+Y)
+ else if (
+ (e.key === 'z' && (e.ctrlKey || e.metaKey) && e.shiftKey) ||
+ (e.key === 'y' && (e.ctrlKey || e.metaKey))
+ ) {
+ e.preventDefault();
+ handleRedo();
+ }
+ // Close
+ else if (e.key === 'Escape') {
+ settingsState.closeConfigEditor();
+ }
+ // Tab
+ else if (e.key === 'Tab') {
+ e.preventDefault();
+ const target = e.target as HTMLTextAreaElement;
+ const start = target.selectionStart;
+ const end = target.selectionEnd;
+
+ pushHistory(content, true);
+
+ const newContent = content.substring(0, start) + " " + content.substring(end);
+ content = newContent;
+
+ pushHistory(content, true);
+
+ setTimeout(() => {
+ target.selectionStart = target.selectionEnd = start + 2;
+ updateCursor();
+ }, 0);
+ validate(content);
+ }
+ }
+</script>
+
+<div class="fixed inset-0 z-[100] flex items-center justify-center backdrop-blur-sm bg-black/70 animate-in fade-in duration-200">
+ <div
+ class="bg-[#1d1f21] rounded-xl border border-zinc-700 shadow-2xl w-[900px] max-w-[95vw] h-[85vh] flex flex-col overflow-hidden"
+ role="dialog"
+ aria-modal="true"
+ >
+ <!-- Header -->
+ <div class="flex items-center justify-between p-4 border-b border-zinc-700 bg-[#1d1f21] z-20 relative">
+ <div class="flex items-center gap-3">
+ <div class="p-2 bg-indigo-500/20 rounded-lg text-indigo-400">
+ <FileJson size={20} />
+ </div>
+ <div class="flex flex-col">
+ <h3 class="text-lg font-bold text-white leading-none">Configuration Editor</h3>
+ <span class="text-[10px] text-zinc-500 font-mono mt-1 break-all">{settingsState.configFilePath}</span>
+ </div>
+ </div>
+ <div class="flex items-center gap-2">
+ <!-- Undo/Redo Buttons -->
+ <div class="flex items-center bg-zinc-800 rounded-lg p-0.5 mr-2 border border-zinc-700">
+ <button
+ onclick={handleUndo}
+ disabled={historyIndex === 0}
+ class="p-1.5 text-zinc-400 hover:text-white hover:bg-zinc-700 rounded disabled:opacity-30 disabled:hover:bg-transparent transition-colors"
+ title="Undo (Ctrl+Z)"
+ >
+ <Undo size={16} />
+ </button>
+ <button
+ onclick={handleRedo}
+ disabled={historyIndex === history.length - 1}
+ class="p-1.5 text-zinc-400 hover:text-white hover:bg-zinc-700 rounded disabled:opacity-30 disabled:hover:bg-transparent transition-colors"
+ title="Redo (Ctrl+Y)"
+ >
+ <Redo size={16} />
+ </button>
+ </div>
+
+ <!-- Settings Toggle -->
+ <div class="relative">
+ <button
+ onclick={() => showSettings = !showSettings}
+ class="text-zinc-400 hover:text-white transition-colors p-2 hover:bg-white/5 rounded-lg {showSettings ? 'bg-white/10 text-white' : ''}"
+ title="Editor Settings"
+ >
+ <Settings size={20} />
+ </button>
+
+ {#if showSettings}
+ <div class="absolute right-0 top-full mt-2 w-48 bg-zinc-800 border border-zinc-700 rounded-lg shadow-xl p-2 z-50 flex flex-col gap-1">
+ <label class="flex items-center gap-2 p-2 hover:bg-white/5 rounded cursor-pointer">
+ <input type="checkbox" bind:checked={showLineNumbers} class="rounded border-zinc-600 bg-zinc-900 text-indigo-500 focus:ring-indigo-500/50" />
+ <span class="text-sm text-zinc-300">Line Numbers</span>
+ </label>
+ <label class="flex items-center gap-2 p-2 hover:bg-white/5 rounded cursor-pointer">
+ <input type="checkbox" bind:checked={showStatusBar} class="rounded border-zinc-600 bg-zinc-900 text-indigo-500 focus:ring-indigo-500/50" />
+ <span class="text-sm text-zinc-300">Cursor Status</span>
+ </label>
+ </div>
+ {/if}
+ </div>
+
+ <button
+ onclick={() => settingsState.closeConfigEditor()}
+ class="text-zinc-400 hover:text-white transition-colors p-2 hover:bg-white/5 rounded-lg"
+ title="Close (Esc)"
+ >
+ <X size={20} />
+ </button>
+ </div>
+ </div>
+
+ <!-- Error Banner -->
+ {#if localError || settingsState.configEditorError}
+ <div class="bg-red-500/10 border-b border-red-500/20 p-3 flex items-start gap-3 animate-in slide-in-from-top-2 z-10 relative">
+ <AlertCircle size={16} class="text-red-400 mt-0.5 shrink-0" />
+ <p class="text-xs text-red-300 font-mono whitespace-pre-wrap">{localError || settingsState.configEditorError}</p>
+ </div>
+ {/if}
+
+ <!-- Editor Body (Flex row for line numbers + code) -->
+ <div class="flex-1 flex overflow-hidden relative bg-[#1d1f21]">
+ <!-- Line Numbers -->
+ {#if showLineNumbers}
+ <div
+ bind:this={lineNumbersRef}
+ class="pt-4 pb-4 pr-3 pl-2 text-right text-zinc-600 bg-[#1d1f21] border-r border-zinc-700/50 font-mono select-none overflow-hidden min-w-[3rem]"
+ aria-hidden="true"
+ >
+ {#each lines as _, i}
+ <div class="leading-[20px] text-[13px]">{i + 1}</div>
+ {/each}
+ </div>
+ {/if}
+
+ <!-- Code Area -->
+ <div class="flex-1 relative overflow-hidden group">
+ <!-- Highlighted Code (Background) -->
+ <pre
+ bind:this={preRef}
+ aria-hidden="true"
+ class="absolute inset-0 w-full h-full p-4 m-0 bg-transparent pointer-events-none overflow-hidden whitespace-pre font-mono text-sm leading-relaxed"
+ ><code class="language-json">{@html highlightedCode}</code></pre>
+
+ <!-- Textarea (Foreground) -->
+ <textarea
+ bind:this={textareaRef}
+ bind:value={content}
+ oninput={handleInput}
+ onkeydown={handleKeydown}
+ onscroll={handleScroll}
+ onmouseup={updateCursor}
+ onkeyup={updateCursor}
+ onclick={() => showSettings = false}
+ class="absolute inset-0 w-full h-full p-4 bg-transparent text-transparent caret-white font-mono text-sm leading-relaxed resize-none focus:outline-none whitespace-pre overflow-auto z-10 selection:bg-indigo-500/30"
+ spellcheck="false"
+ {...textareaAttrs}
+ ></textarea>
+ </div>
+ </div>
+
+ <!-- Footer -->
+ <div class="p-3 border-t border-zinc-700 bg-[#1d1f21] flex justify-between items-center z-20 relative">
+ <div class="text-xs text-zinc-500 flex gap-4 items-center">
+ {#if showStatusBar}
+ <div class="flex gap-3 font-mono border-r border-zinc-700 pr-4 mr-1">
+ <span>Ln {cursorLine}</span>
+ <span>Col {cursorCol}</span>
+ </div>
+ {/if}
+ <span class="hidden sm:inline"><span class="bg-white/10 px-1.5 py-0.5 rounded text-zinc-300">Ctrl+S</span> save</span>
+ </div>
+ <div class="flex gap-3">
+ <button
+ onclick={() => settingsState.closeConfigEditor()}
+ class="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-white rounded-lg text-sm font-medium transition-colors"
+ >
+ Cancel
+ </button>
+ <button
+ onclick={() => handleSave(false)}
+ disabled={isSaving || !!localError}
+ class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
+ title={localError ? "Fix errors before saving" : "Save changes"}
+ >
+ {#if isSaving}
+ <div class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
+ Saving...
+ {:else}
+ <Save size={16} />
+ Save
+ {/if}
+ </button>
+ </div>
+ </div>
+ </div>
+</div>
+
+<style>
+ /* Ensure exact font match */
+ pre, textarea {
+ font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
+ font-size: 13px !important;
+ line-height: 20px !important;
+ letter-spacing: 0px !important;
+ tab-size: 2;
+ }
+
+ /* Hide scrollbar for pre but keep it functional for textarea */
+ pre::-webkit-scrollbar {
+ display: none;
+ }
+
+ /* Override Prism background and font weights for alignment */
+ :global(pre[class*="language-"]), :global(code[class*="language-"]) {
+ background: transparent !important;
+ text-shadow: none !important;
+ box-shadow: none !important;
+ }
+
+ /* CRITICAL: Force normal weight to match textarea */
+ :global(.token) {
+ font-weight: normal !important;
+ font-style: normal !important;
+ }
+</style>
diff --git a/ui/src/components/CustomSelect.svelte b/ui/src/components/CustomSelect.svelte
index 2e89c75..0767471 100644
--- a/ui/src/components/CustomSelect.svelte
+++ b/ui/src/components/CustomSelect.svelte
@@ -13,6 +13,7 @@
placeholder?: string;
disabled?: boolean;
class?: string;
+ allowCustom?: boolean; // New prop to allow custom input
onchange?: (value: string) => void;
}
@@ -22,17 +23,25 @@
placeholder = "Select...",
disabled = false,
class: className = "",
+ allowCustom = false,
onchange
}: Props = $props();
let isOpen = $state(false);
let containerRef: HTMLDivElement;
+ let customInput = $state(""); // State for custom input
let selectedOption = $derived(options.find(o => o.value === value));
+ // Display label: if option exists use its label, otherwise if custom is allowed use raw value, else placeholder
+ let displayLabel = $derived(selectedOption ? selectedOption.label : (allowCustom && value ? value : placeholder));
function toggle() {
if (!disabled) {
isOpen = !isOpen;
+ // When opening, if current value is custom (not in options), pre-fill input
+ if (isOpen && allowCustom && !selectedOption) {
+ customInput = value;
+ }
}
}
@@ -43,6 +52,13 @@
onchange?.(option.value);
}
+ function handleCustomSubmit() {
+ if (!customInput.trim()) return;
+ value = customInput.trim();
+ isOpen = false;
+ onchange?.(value);
+ }
+
function handleKeydown(e: KeyboardEvent) {
if (disabled) return;
@@ -98,8 +114,8 @@
transition-colors cursor-pointer outline-none
disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-zinc-700"
>
- <span class="truncate {!selectedOption ? 'text-zinc-500' : ''}">
- {selectedOption?.label || placeholder}
+ <span class="truncate {(!selectedOption && !value) ? 'text-zinc-500' : ''}">
+ {displayLabel}
</span>
<ChevronDown
size={14}
@@ -111,8 +127,29 @@
{#if isOpen}
<div
class="absolute z-50 w-full mt-1 py-1 bg-zinc-900 border border-zinc-700 rounded-md shadow-xl
- max-h-60 overflow-y-auto animate-in fade-in slide-in-from-top-1 duration-150"
+ max-h-60 overflow-y-auto animate-in fade-in slide-in-from-top-1 duration-150 flex flex-col"
>
+ {#if allowCustom}
+ <div class="px-2 py-2 border-b border-zinc-700/50 mb-1">
+ <div class="flex gap-2">
+ <input
+ type="text"
+ bind:value={customInput}
+ placeholder="Custom value..."
+ class="flex-1 bg-black/30 border border-zinc-700 rounded px-2 py-1 text-xs text-white focus:border-indigo-500 outline-none"
+ onkeydown={(e) => e.key === 'Enter' && handleCustomSubmit()}
+ onclick={(e) => e.stopPropagation()}
+ />
+ <button
+ onclick={(e) => { e.stopPropagation(); handleCustomSubmit(); }}
+ class="px-2 py-1 bg-indigo-600 hover:bg-indigo-500 text-white rounded text-xs transition-colors"
+ >
+ Set
+ </button>
+ </div>
+ </div>
+ {/if}
+
{#each options as option}
<button
type="button"
diff --git a/ui/src/components/InstancesView.svelte b/ui/src/components/InstancesView.svelte
new file mode 100644
index 0000000..a4881e6
--- /dev/null
+++ b/ui/src/components/InstancesView.svelte
@@ -0,0 +1,331 @@
+<script lang="ts">
+ import { onMount } from "svelte";
+ import { instancesState } from "../stores/instances.svelte";
+ import { Plus, Trash2, Edit2, Copy, Check, X } from "lucide-svelte";
+ import type { Instance } from "../types";
+
+ let showCreateModal = $state(false);
+ let showEditModal = $state(false);
+ let showDeleteConfirm = $state(false);
+ let showDuplicateModal = $state(false);
+ let selectedInstance: Instance | null = $state(null);
+ let newInstanceName = $state("");
+ let duplicateName = $state("");
+
+ onMount(() => {
+ instancesState.loadInstances();
+ });
+
+ function handleCreate() {
+ newInstanceName = "";
+ showCreateModal = true;
+ }
+
+ function handleEdit(instance: Instance) {
+ selectedInstance = instance;
+ newInstanceName = instance.name;
+ showEditModal = true;
+ }
+
+ function handleDelete(instance: Instance) {
+ selectedInstance = instance;
+ showDeleteConfirm = true;
+ }
+
+ function handleDuplicate(instance: Instance) {
+ selectedInstance = instance;
+ duplicateName = `${instance.name} (Copy)`;
+ showDuplicateModal = true;
+ }
+
+ async function confirmCreate() {
+ if (!newInstanceName.trim()) return;
+ await instancesState.createInstance(newInstanceName.trim());
+ showCreateModal = false;
+ newInstanceName = "";
+ }
+
+ async function confirmEdit() {
+ if (!selectedInstance || !newInstanceName.trim()) return;
+ await instancesState.updateInstance({
+ ...selectedInstance,
+ name: newInstanceName.trim(),
+ });
+ showEditModal = false;
+ selectedInstance = null;
+ newInstanceName = "";
+ }
+
+ async function confirmDelete() {
+ if (!selectedInstance) return;
+ await instancesState.deleteInstance(selectedInstance.id);
+ showDeleteConfirm = false;
+ selectedInstance = null;
+ }
+
+ async function confirmDuplicate() {
+ if (!selectedInstance || !duplicateName.trim()) return;
+ await instancesState.duplicateInstance(selectedInstance.id, duplicateName.trim());
+ showDuplicateModal = false;
+ selectedInstance = null;
+ duplicateName = "";
+ }
+
+ function formatDate(timestamp: number): string {
+ return new Date(timestamp * 1000).toLocaleDateString();
+ }
+
+ function formatLastPlayed(timestamp: number): string {
+ const date = new Date(timestamp * 1000);
+ const now = new Date();
+ const diff = now.getTime() - date.getTime();
+ const days = Math.floor(diff / (1000 * 60 * 60 * 24));
+
+ if (days === 0) return "Today";
+ if (days === 1) return "Yesterday";
+ if (days < 7) return `${days} days ago`;
+ return date.toLocaleDateString();
+ }
+</script>
+
+<div class="h-full flex flex-col gap-4 p-6 overflow-y-auto">
+ <div class="flex items-center justify-between">
+ <h1 class="text-2xl font-bold text-gray-900 dark:text-white">Instances</h1>
+ <button
+ onclick={handleCreate}
+ class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
+ >
+ <Plus size={18} />
+ Create Instance
+ </button>
+ </div>
+
+ {#if instancesState.instances.length === 0}
+ <div class="flex-1 flex items-center justify-center">
+ <div class="text-center text-gray-500 dark:text-gray-400">
+ <p class="text-lg mb-2">No instances yet</p>
+ <p class="text-sm">Create your first instance to get started</p>
+ </div>
+ </div>
+ {:else}
+ <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
+ {#each instancesState.instances as instance (instance.id)}
+ <div
+ class="relative p-4 bg-gray-100 dark:bg-gray-800 rounded-lg border-2 transition-all cursor-pointer hover:border-blue-500 {instancesState.activeInstanceId === instance.id
+ ? 'border-blue-500'
+ : 'border-transparent'}"
+ onclick={() => instancesState.setActiveInstance(instance.id)}
+ >
+ {#if instancesState.activeInstanceId === instance.id}
+ <div class="absolute top-2 right-2">
+ <div class="w-3 h-3 bg-blue-500 rounded-full"></div>
+ </div>
+ {/if}
+
+ <div class="flex items-start justify-between mb-2">
+ <h3 class="text-lg font-semibold text-gray-900 dark:text-white">
+ {instance.name}
+ </h3>
+ <div class="flex gap-1">
+ <button
+ onclick={(e) => {
+ e.stopPropagation();
+ handleEdit(instance);
+ }}
+ class="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
+ title="Edit"
+ >
+ <Edit2 size={16} class="text-gray-600 dark:text-gray-400" />
+ </button>
+ <button
+ onclick={(e) => {
+ e.stopPropagation();
+ handleDuplicate(instance);
+ }}
+ class="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
+ title="Duplicate"
+ >
+ <Copy size={16} class="text-gray-600 dark:text-gray-400" />
+ </button>
+ <button
+ onclick={(e) => {
+ e.stopPropagation();
+ handleDelete(instance);
+ }}
+ class="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors"
+ title="Delete"
+ >
+ <Trash2 size={16} class="text-red-600 dark:text-red-400" />
+ </button>
+ </div>
+ </div>
+
+ <div class="space-y-1 text-sm text-gray-600 dark:text-gray-400">
+ {#if instance.version_id}
+ <p>Version: <span class="font-medium">{instance.version_id}</span></p>
+ {:else}
+ <p class="text-gray-400">No version selected</p>
+ {/if}
+
+ {#if instance.mod_loader && instance.mod_loader !== "vanilla"}
+ <p>
+ Mod Loader: <span class="font-medium capitalize">{instance.mod_loader}</span>
+ {#if instance.mod_loader_version}
+ <span class="text-gray-500">({instance.mod_loader_version})</span>
+ {/if}
+ </p>
+ {/if}
+
+ <p>Created: {formatDate(instance.created_at)}</p>
+
+ {#if instance.last_played}
+ <p>Last played: {formatLastPlayed(instance.last_played)}</p>
+ {/if}
+ </div>
+
+ {#if instance.notes}
+ <p class="mt-2 text-sm text-gray-500 dark:text-gray-500 italic">
+ {instance.notes}
+ </p>
+ {/if}
+ </div>
+ {/each}
+ </div>
+ {/if}
+</div>
+
+<!-- Create Modal -->
+{#if showCreateModal}
+ <div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
+ <div class="bg-white dark:bg-gray-800 rounded-lg p-6 w-96">
+ <h2 class="text-xl font-bold mb-4 text-gray-900 dark:text-white">Create Instance</h2>
+ <input
+ type="text"
+ bind:value={newInstanceName}
+ placeholder="Instance name"
+ class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white mb-4"
+ onkeydown={(e) => e.key === "Enter" && confirmCreate()}
+ autofocus
+ />
+ <div class="flex gap-2 justify-end">
+ <button
+ onclick={() => {
+ showCreateModal = false;
+ newInstanceName = "";
+ }}
+ class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
+ >
+ Cancel
+ </button>
+ <button
+ onclick={confirmCreate}
+ disabled={!newInstanceName.trim()}
+ class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
+ >
+ Create
+ </button>
+ </div>
+ </div>
+ </div>
+{/if}
+
+<!-- Edit Modal -->
+{#if showEditModal && selectedInstance}
+ <div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
+ <div class="bg-white dark:bg-gray-800 rounded-lg p-6 w-96">
+ <h2 class="text-xl font-bold mb-4 text-gray-900 dark:text-white">Edit Instance</h2>
+ <input
+ type="text"
+ bind:value={newInstanceName}
+ placeholder="Instance name"
+ class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white mb-4"
+ onkeydown={(e) => e.key === "Enter" && confirmEdit()}
+ autofocus
+ />
+ <div class="flex gap-2 justify-end">
+ <button
+ onclick={() => {
+ showEditModal = false;
+ selectedInstance = null;
+ newInstanceName = "";
+ }}
+ class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
+ >
+ Cancel
+ </button>
+ <button
+ onclick={confirmEdit}
+ disabled={!newInstanceName.trim()}
+ class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
+ >
+ Save
+ </button>
+ </div>
+ </div>
+ </div>
+{/if}
+
+<!-- Delete Confirmation -->
+{#if showDeleteConfirm && selectedInstance}
+ <div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
+ <div class="bg-white dark:bg-gray-800 rounded-lg p-6 w-96">
+ <h2 class="text-xl font-bold mb-4 text-red-600 dark:text-red-400">Delete Instance</h2>
+ <p class="mb-4 text-gray-700 dark:text-gray-300">
+ Are you sure you want to delete "{selectedInstance.name}"? This action cannot be undone and will delete all game data for this instance.
+ </p>
+ <div class="flex gap-2 justify-end">
+ <button
+ onclick={() => {
+ showDeleteConfirm = false;
+ selectedInstance = null;
+ }}
+ class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
+ >
+ Cancel
+ </button>
+ <button
+ onclick={confirmDelete}
+ class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
+ >
+ Delete
+ </button>
+ </div>
+ </div>
+ </div>
+{/if}
+
+<!-- Duplicate Modal -->
+{#if showDuplicateModal && selectedInstance}
+ <div class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
+ <div class="bg-white dark:bg-gray-800 rounded-lg p-6 w-96">
+ <h2 class="text-xl font-bold mb-4 text-gray-900 dark:text-white">Duplicate Instance</h2>
+ <input
+ type="text"
+ bind:value={duplicateName}
+ placeholder="New instance name"
+ class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white mb-4"
+ onkeydown={(e) => e.key === "Enter" && confirmDuplicate()}
+ autofocus
+ />
+ <div class="flex gap-2 justify-end">
+ <button
+ onclick={() => {
+ showDuplicateModal = false;
+ selectedInstance = null;
+ duplicateName = "";
+ }}
+ class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
+ >
+ Cancel
+ </button>
+ <button
+ onclick={confirmDuplicate}
+ disabled={!duplicateName.trim()}
+ class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
+ >
+ Duplicate
+ </button>
+ </div>
+ </div>
+ </div>
+{/if}
diff --git a/ui/src/components/ModLoaderSelector.svelte b/ui/src/components/ModLoaderSelector.svelte
index e9d147b..50caa8c 100644
--- a/ui/src/components/ModLoaderSelector.svelte
+++ b/ui/src/components/ModLoaderSelector.svelte
@@ -9,6 +9,7 @@
} from "../types";
import { Loader2, Download, AlertCircle, Check, ChevronDown, CheckCircle } from 'lucide-svelte';
import { logsState } from "../stores/logs.svelte";
+ import { instancesState } from "../stores/instances.svelte";
interface Props {
selectedGameVersion: string;
@@ -52,12 +53,13 @@
});
async function checkInstallStatus() {
- if (!selectedGameVersion) {
+ if (!selectedGameVersion || !instancesState.activeInstanceId) {
isVersionInstalled = false;
return;
}
try {
isVersionInstalled = await invoke<boolean>("check_version_installed", {
+ instanceId: instancesState.activeInstanceId,
versionId: selectedGameVersion,
});
} catch (e) {
@@ -112,8 +114,13 @@
error = null;
logsState.addLog("info", "Installer", `Starting installation of ${selectedGameVersion}...`);
+ if (!instancesState.activeInstanceId) {
+ error = "Please select an instance first";
+ return;
+ }
try {
await invoke("install_version", {
+ instanceId: instancesState.activeInstanceId,
versionId: selectedGameVersion,
});
logsState.addLog("info", "Installer", `Successfully installed ${selectedGameVersion}`);
@@ -134,6 +141,12 @@
return;
}
+ if (!instancesState.activeInstanceId) {
+ error = "Please select an instance first";
+ isInstalling = false;
+ return;
+ }
+
isInstalling = true;
error = null;
@@ -142,6 +155,7 @@
if (!isVersionInstalled) {
logsState.addLog("info", "Installer", `Installing base game ${selectedGameVersion} first...`);
await invoke("install_version", {
+ instanceId: instancesState.activeInstanceId,
versionId: selectedGameVersion,
});
isVersionInstalled = true;
@@ -151,6 +165,7 @@
if (selectedLoader === "fabric" && selectedFabricLoader) {
logsState.addLog("info", "Installer", `Installing Fabric ${selectedFabricLoader} for ${selectedGameVersion}...`);
const result = await invoke<any>("install_fabric", {
+ instanceId: instancesState.activeInstanceId,
gameVersion: selectedGameVersion,
loaderVersion: selectedFabricLoader,
});
@@ -159,6 +174,7 @@
} else if (selectedLoader === "forge" && selectedForgeVersion) {
logsState.addLog("info", "Installer", `Installing Forge ${selectedForgeVersion} for ${selectedGameVersion}...`);
const result = await invoke<any>("install_forge", {
+ instanceId: instancesState.activeInstanceId,
gameVersion: selectedGameVersion,
forgeVersion: selectedForgeVersion,
});
@@ -291,7 +307,12 @@
{:else if selectedLoader === "fabric"}
<div class="space-y-4 animate-in fade-in slide-in-from-bottom-2 duration-300">
- <div>
+ {#if fabricLoaders.length === 0}
+ <div class="text-center p-4 text-sm text-zinc-500 italic">
+ No Fabric versions available for {selectedGameVersion}
+ </div>
+ {:else}
+ <div>
<label for="fabric-loader-select" class="block text-[10px] uppercase font-bold text-zinc-500 mb-2"
>Loader Version</label
>
@@ -339,21 +360,22 @@
</div>
{/if}
</div>
- </div>
-
- <button
- class="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white py-2.5 px-4 rounded-sm font-bold text-sm transition-all flex items-center justify-center gap-2"
- onclick={installModLoader}
- disabled={isInstalling || !selectedFabricLoader}
- >
- {#if isInstalling}
- <Loader2 class="animate-spin" size={16} />
- Installing...
- {:else}
- <Download size={16} />
- Install Fabric {selectedFabricLoader}
- {/if}
- </button>
+ </div>
+
+ <button
+ class="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white py-2.5 px-4 rounded-sm font-bold text-sm transition-all flex items-center justify-center gap-2"
+ onclick={installModLoader}
+ disabled={isInstalling || !selectedFabricLoader}
+ >
+ {#if isInstalling}
+ <Loader2 class="animate-spin" size={16} />
+ Installing...
+ {:else}
+ <Download size={16} />
+ Install Fabric {selectedFabricLoader}
+ {/if}
+ </button>
+ {/if}
</div>
{:else if selectedLoader === "forge"}
diff --git a/ui/src/components/SettingsView.svelte b/ui/src/components/SettingsView.svelte
index 76d441b..4de18b3 100644
--- a/ui/src/components/SettingsView.svelte
+++ b/ui/src/components/SettingsView.svelte
@@ -2,6 +2,9 @@
import { open } from "@tauri-apps/plugin-dialog";
import { settingsState } from "../stores/settings.svelte";
import CustomSelect from "./CustomSelect.svelte";
+ import ConfigEditorModal from "./ConfigEditorModal.svelte";
+ import { onMount } from "svelte";
+ import { RefreshCw, FileJson } from "lucide-svelte";
// Use convertFileSrc directly from settingsState.backgroundUrl for cleaner approach
// or use the imported one if passing raw path.
@@ -17,6 +20,84 @@
{ value: "pastebin.com", label: "pastebin.com (Requires API Key)" }
];
+ const llmProviderOptions = [
+ { value: "ollama", label: "Ollama (Local)" },
+ { value: "openai", label: "OpenAI (Remote)" }
+ ];
+
+ const languageOptions = [
+ { value: "auto", label: "Auto (Match User)" },
+ { value: "English", label: "English" },
+ { value: "Chinese", label: "中文" },
+ { value: "Japanese", label: "日本語" },
+ { value: "Korean", label: "한국어" },
+ { value: "Spanish", label: "Español" },
+ { value: "French", label: "Français" },
+ { value: "German", label: "Deutsch" },
+ { value: "Russian", label: "Русский" },
+ ];
+
+ const ttsProviderOptions = [
+ { value: "disabled", label: "Disabled" },
+ { value: "piper", label: "Piper TTS (Local)" },
+ { value: "edge", label: "Edge TTS (Online)" },
+ ];
+
+ const personas = [
+ {
+ value: "default",
+ label: "Minecraft Expert (Default)",
+ 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."
+ },
+ {
+ value: "technical",
+ label: "Technical Debugger",
+ prompt: "You are a technical support specialist for Minecraft. Focus strictly on analyzing logs, identifying crash causes, and providing technical solutions. Be precise and avoid conversational filler."
+ },
+ {
+ value: "concise",
+ label: "Concise Helper",
+ prompt: "You are a direct and concise assistant. Provide answers in as few words as possible while remaining accurate. Use bullet points for lists."
+ },
+ {
+ value: "explain",
+ label: "Teacher / Explainer",
+ prompt: "You are a patient teacher. Explain Minecraft concepts, redstone mechanics, and mod features in simple, easy-to-understand terms suitable for beginners."
+ },
+ {
+ value: "pirate",
+ label: "Pirate Captain",
+ prompt: "You are a salty Minecraft Pirate Captain! Yarr! Speak like a pirate while helping the crew (the user) with their blocky adventures. Use terms like 'matey', 'landlubber', and 'treasure'."
+ }
+ ];
+
+ let selectedPersona = $state("");
+
+ function applyPersona(value: string) {
+ const persona = personas.find(p => p.value === value);
+ if (persona) {
+ settingsState.settings.assistant.system_prompt = persona.prompt;
+ selectedPersona = value; // Keep selected to show what's active
+ }
+ }
+
+ function resetSystemPrompt() {
+ const defaultPersona = personas.find(p => p.value === "default");
+ if (defaultPersona) {
+ settingsState.settings.assistant.system_prompt = defaultPersona.prompt;
+ selectedPersona = "default";
+ }
+ }
+
+ // Load models when assistant settings are shown
+ function loadModelsForProvider() {
+ if (settingsState.settings.assistant.llm_provider === "ollama") {
+ settingsState.loadOllamaModels();
+ } else if (settingsState.settings.assistant.llm_provider === "openai") {
+ settingsState.loadOpenaiModels();
+ }
+ }
+
async function selectBackground() {
try {
const selected = await open({
@@ -47,6 +128,15 @@
<div class="h-full flex flex-col p-6 overflow-hidden">
<div class="flex items-center justify-between mb-6">
<h2 class="text-3xl font-black bg-clip-text text-transparent bg-gradient-to-r dark:from-white dark:to-white/60 from-gray-900 to-gray-600">Settings</h2>
+
+ <button
+ onclick={() => settingsState.openConfigEditor()}
+ class="p-2 hover:bg-white/10 rounded-lg text-zinc-400 hover:text-white transition-colors flex items-center gap-2 text-sm border border-transparent hover:border-white/5"
+ title="Open Settings JSON"
+ >
+ <FileJson size={18} />
+ <span class="hidden sm:inline">Open JSON</span>
+ </button>
</div>
<div class="flex-1 overflow-y-auto pr-2 space-y-6 custom-scrollbar pb-10">
@@ -341,6 +431,298 @@
</div>
</div>
+ <!-- AI Assistant -->
+ <div class="dark:bg-[#09090b] bg-white p-6 rounded-sm border dark:border-white/10 border-gray-200 shadow-sm">
+ <h3 class="text-xs font-bold uppercase tracking-widest text-white/40 mb-6 flex items-center gap-2">
+ <svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+ <rect x="3" y="11" width="18" height="10" rx="2"/>
+ <circle cx="12" cy="5" r="2"/>
+ <path d="M12 7v4"/>
+ <circle cx="8" cy="16" r="1" fill="currentColor"/>
+ <circle cx="16" cy="16" r="1" fill="currentColor"/>
+ </svg>
+ AI Assistant
+ </h3>
+ <div class="space-y-6">
+ <!-- Enable/Disable -->
+ <div class="flex items-center justify-between">
+ <div>
+ <h4 class="text-sm font-medium dark:text-white/90 text-black/80" id="assistant-enabled-label">Enable Assistant</h4>
+ <p class="text-xs dark:text-white/40 text-black/50 mt-1">Toggle the AI assistant feature on or off.</p>
+ </div>
+ <button
+ aria-labelledby="assistant-enabled-label"
+ onclick={() => { settingsState.settings.assistant.enabled = !settingsState.settings.assistant.enabled; settingsState.saveSettings(); }}
+ class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none {settingsState.settings.assistant.enabled ? 'bg-indigo-500' : 'dark:bg-white/10 bg-black/10'}"
+ >
+ <div class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full shadow-sm transition-transform duration-200 ease-in-out {settingsState.settings.assistant.enabled ? 'translate-x-5' : 'translate-x-0'}"></div>
+ </button>
+ </div>
+
+ {#if settingsState.settings.assistant.enabled}
+ <!-- LLM Provider Section -->
+ <div class="pt-4 border-t dark:border-white/5 border-black/5">
+ <h4 class="text-xs font-bold uppercase tracking-widest text-white/30 mb-4">Language Model</h4>
+
+ <div class="space-y-4">
+ <div>
+ <label for="llm-provider" class="block text-sm font-medium text-white/70 mb-2">Provider</label>
+ <CustomSelect
+ options={llmProviderOptions}
+ bind:value={settingsState.settings.assistant.llm_provider}
+ onchange={() => settingsState.saveSettings()}
+ class="w-full"
+ />
+ </div>
+
+ {#if settingsState.settings.assistant.llm_provider === 'ollama'}
+ <!-- Ollama Settings -->
+ <div class="pl-4 border-l-2 border-indigo-500/30 space-y-4">
+ <div>
+ <label for="ollama-endpoint" class="block text-sm font-medium text-white/70 mb-2">API Endpoint</label>
+ <div class="flex gap-2">
+ <input
+ id="ollama-endpoint"
+ type="text"
+ bind:value={settingsState.settings.assistant.ollama_endpoint}
+ placeholder="http://localhost:11434"
+ class="bg-black/40 text-white flex-1 px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none font-mono text-xs transition-colors"
+ />
+ <button
+ onclick={() => settingsState.loadOllamaModels()}
+ disabled={settingsState.isLoadingOllamaModels}
+ class="px-4 py-2 bg-white/10 hover:bg-white/20 disabled:opacity-50 text-white rounded-xl border border-white/5 transition-colors whitespace-nowrap text-sm font-medium flex items-center gap-2"
+ title="Refresh models"
+ >
+ <RefreshCw size={14} class={settingsState.isLoadingOllamaModels ? "animate-spin" : ""} />
+ <span class="hidden sm:inline">Refresh</span>
+ </button>
+ </div>
+ <p class="text-xs text-white/30 mt-2">
+ Default: http://localhost:11434. Make sure Ollama is running.
+ </p>
+ </div>
+
+ <div>
+ <div class="flex items-center justify-between mb-2">
+ <label for="ollama-model" class="block text-sm font-medium text-white/70">Model</label>
+ {#if settingsState.ollamaModels.length > 0}
+ <span class="text-[10px] text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full">
+ {settingsState.ollamaModels.length} installed
+ </span>
+ {/if}
+ </div>
+
+ {#if settingsState.isLoadingOllamaModels}
+ <div class="bg-black/40 text-white/50 w-full px-4 py-3 rounded-xl border border-white/10 text-sm flex items-center gap-2">
+ <RefreshCw size={14} class="animate-spin" />
+ Loading models...
+ </div>
+ {:else if settingsState.ollamaModelsError}
+ <div class="bg-red-500/10 text-red-400 w-full px-4 py-3 rounded-xl border border-red-500/20 text-sm">
+ {settingsState.ollamaModelsError}
+ </div>
+ <CustomSelect
+ options={settingsState.currentModelOptions}
+ bind:value={settingsState.settings.assistant.ollama_model}
+ onchange={() => settingsState.saveSettings()}
+ class="w-full mt-2"
+ allowCustom={true}
+ />
+ {:else if settingsState.ollamaModels.length === 0}
+ <div class="bg-amber-500/10 text-amber-400 w-full px-4 py-3 rounded-xl border border-amber-500/20 text-sm">
+ No models found. Click Refresh to load installed models.
+ </div>
+ <CustomSelect
+ options={settingsState.currentModelOptions}
+ bind:value={settingsState.settings.assistant.ollama_model}
+ onchange={() => settingsState.saveSettings()}
+ class="w-full mt-2"
+ allowCustom={true}
+ />
+ {:else}
+ <CustomSelect
+ options={settingsState.currentModelOptions}
+ bind:value={settingsState.settings.assistant.ollama_model}
+ onchange={() => settingsState.saveSettings()}
+ class="w-full"
+ allowCustom={true}
+ />
+ {/if}
+
+ <p class="text-xs text-white/30 mt-2">
+ Run <code class="bg-black/30 px-1 rounded">ollama pull {'<model>'}</code> to download new models. Or type a custom model name above.
+ </p>
+ </div>
+ </div>
+ {:else if settingsState.settings.assistant.llm_provider === 'openai'}
+ <!-- OpenAI Settings -->
+ <div class="pl-4 border-l-2 border-emerald-500/30 space-y-4">
+ <div>
+ <label for="openai-key" class="block text-sm font-medium text-white/70 mb-2">API Key</label>
+ <div class="flex gap-2">
+ <input
+ id="openai-key"
+ type="password"
+ bind:value={settingsState.settings.assistant.openai_api_key}
+ placeholder="sk-..."
+ class="bg-black/40 text-white flex-1 px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none font-mono text-xs transition-colors"
+ />
+ <button
+ onclick={() => settingsState.loadOpenaiModels()}
+ disabled={settingsState.isLoadingOpenaiModels || !settingsState.settings.assistant.openai_api_key}
+ class="px-4 py-2 bg-white/10 hover:bg-white/20 disabled:opacity-50 text-white rounded-xl border border-white/5 transition-colors whitespace-nowrap text-sm font-medium flex items-center gap-2"
+ title="Refresh models"
+ >
+ <RefreshCw size={14} class={settingsState.isLoadingOpenaiModels ? "animate-spin" : ""} />
+ <span class="hidden sm:inline">Load</span>
+ </button>
+ </div>
+ <p class="text-xs text-white/30 mt-2">
+ Get your API key from <a href="https://platform.openai.com/api-keys" target="_blank" class="text-indigo-400 hover:underline">OpenAI Dashboard</a>.
+ </p>
+ </div>
+
+ <div>
+ <label for="openai-endpoint" class="block text-sm font-medium text-white/70 mb-2">API Endpoint</label>
+ <input
+ id="openai-endpoint"
+ type="text"
+ bind:value={settingsState.settings.assistant.openai_endpoint}
+ placeholder="https://api.openai.com/v1"
+ class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none font-mono text-xs transition-colors"
+ />
+ <p class="text-xs text-white/30 mt-2">
+ Use custom endpoint for Azure OpenAI or other compatible APIs.
+ </p>
+ </div>
+
+ <div>
+ <div class="flex items-center justify-between mb-2">
+ <label for="openai-model" class="block text-sm font-medium text-white/70">Model</label>
+ {#if settingsState.openaiModels.length > 0}
+ <span class="text-[10px] text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-full">
+ {settingsState.openaiModels.length} available
+ </span>
+ {/if}
+ </div>
+
+ {#if settingsState.isLoadingOpenaiModels}
+ <div class="bg-black/40 text-white/50 w-full px-4 py-3 rounded-xl border border-white/10 text-sm flex items-center gap-2">
+ <RefreshCw size={14} class="animate-spin" />
+ Loading models...
+ </div>
+ {:else if settingsState.openaiModelsError}
+ <div class="bg-red-500/10 text-red-400 w-full px-4 py-3 rounded-xl border border-red-500/20 text-sm mb-2">
+ {settingsState.openaiModelsError}
+ </div>
+ <CustomSelect
+ options={settingsState.currentModelOptions}
+ bind:value={settingsState.settings.assistant.openai_model}
+ onchange={() => settingsState.saveSettings()}
+ class="w-full"
+ allowCustom={true}
+ />
+ {:else}
+ <CustomSelect
+ options={settingsState.currentModelOptions}
+ bind:value={settingsState.settings.assistant.openai_model}
+ onchange={() => settingsState.saveSettings()}
+ class="w-full"
+ allowCustom={true}
+ />
+ {/if}
+ </div>
+ </div>
+ {/if}
+ </div>
+ </div>
+
+ <!-- Response Settings -->
+ <div class="pt-4 border-t dark:border-white/5 border-black/5">
+ <h4 class="text-xs font-bold uppercase tracking-widest text-white/30 mb-4">Response Settings</h4>
+
+ <div class="space-y-4">
+ <div>
+ <label for="response-lang" class="block text-sm font-medium text-white/70 mb-2">Response Language</label>
+ <CustomSelect
+ options={languageOptions}
+ bind:value={settingsState.settings.assistant.response_language}
+ onchange={() => settingsState.saveSettings()}
+ class="w-full"
+ />
+ </div>
+
+ <div>
+ <div class="flex items-center justify-between mb-2">
+ <label for="system-prompt" class="block text-sm font-medium text-white/70">System Prompt</label>
+ <button
+ onclick={resetSystemPrompt}
+ class="text-xs text-indigo-400 hover:text-indigo-300 transition-colors flex items-center gap-1 opacity-80 hover:opacity-100"
+ title="Reset to default prompt"
+ >
+ <RefreshCw size={10} />
+ Reset
+ </button>
+ </div>
+
+ <div class="mb-3">
+ <CustomSelect
+ options={personas.map(p => ({ value: p.value, label: p.label }))}
+ bind:value={selectedPersona}
+ placeholder="Load a preset persona..."
+ onchange={applyPersona}
+ class="w-full"
+ />
+ </div>
+
+ <textarea
+ id="system-prompt"
+ bind:value={settingsState.settings.assistant.system_prompt}
+ oninput={() => selectedPersona = ""}
+ rows="4"
+ placeholder="You are a helpful Minecraft expert assistant..."
+ class="bg-black/40 text-white w-full px-4 py-3 rounded-xl border border-white/10 focus:border-indigo-500/50 outline-none text-sm transition-colors resize-none"
+ ></textarea>
+ <p class="text-xs text-white/30 mt-2">
+ Customize how the assistant behaves and responds.
+ </p>
+ </div>
+ </div>
+ </div>
+
+ <!-- TTS Settings -->
+ <div class="pt-4 border-t dark:border-white/5 border-black/5">
+ <h4 class="text-xs font-bold uppercase tracking-widest text-white/30 mb-4">Text-to-Speech (Coming Soon)</h4>
+
+ <div class="space-y-4 opacity-50 pointer-events-none">
+ <div class="flex items-center justify-between">
+ <div>
+ <h4 class="text-sm font-medium dark:text-white/90 text-black/80">Enable TTS</h4>
+ <p class="text-xs dark:text-white/40 text-black/50 mt-1">Read assistant responses aloud.</p>
+ </div>
+ <button
+ disabled
+ class="w-11 h-6 rounded-full transition-colors duration-200 ease-in-out relative focus:outline-none dark:bg-white/10 bg-black/10"
+ >
+ <div class="absolute top-1 left-1 bg-white w-4 h-4 rounded-full shadow-sm transition-transform duration-200 ease-in-out translate-x-0"></div>
+ </button>
+ </div>
+
+ <div>
+ <label class="block text-sm font-medium text-white/70 mb-2">TTS Provider</label>
+ <CustomSelect
+ options={ttsProviderOptions}
+ value="disabled"
+ class="w-full"
+ />
+ </div>
+ </div>
+ </div>
+ {/if}
+ </div>
+ </div>
+
<div class="pt-4 flex justify-end">
<button
onclick={() => settingsState.saveSettings()}
@@ -352,6 +734,10 @@
</div>
</div>
+{#if settingsState.showConfigEditor}
+ <ConfigEditorModal />
+{/if}
+
<!-- Java Download Modal -->
{#if settingsState.showJavaDownloadModal}
<div class="fixed inset-0 z-[100] flex items-center justify-center backdrop-blur-sm bg-black/70">
diff --git a/ui/src/components/Sidebar.svelte b/ui/src/components/Sidebar.svelte
index 1d7cc16..83f4ac6 100644
--- a/ui/src/components/Sidebar.svelte
+++ b/ui/src/components/Sidebar.svelte
@@ -1,6 +1,6 @@
<script lang="ts">
import { uiState } from '../stores/ui.svelte';
- import { Home, Package, Settings } from 'lucide-svelte';
+ import { Home, Package, Settings, Bot, Folder } from 'lucide-svelte';
</script>
<aside
@@ -57,7 +57,7 @@
<!-- Navigation -->
<nav class="flex-1 w-full flex flex-col gap-1 px-3">
<!-- Nav Item Helper -->
- {#snippet navItem(view, Icon, label)}
+ {#snippet navItem(view: any, Icon: any, label: string)}
<button
class="group flex items-center lg:gap-3 justify-center lg:justify-start w-full px-0 lg:px-4 py-2.5 rounded-sm transition-all duration-200 relative
{uiState.currentView === view
@@ -76,7 +76,9 @@
{/snippet}
{@render navItem('home', Home, 'Overview')}
+ {@render navItem('instances', Folder, 'Instances')}
{@render navItem('versions', Package, 'Versions')}
+ {@render navItem('guide', Bot, 'Assistant')}
{@render navItem('settings', Settings, 'Settings')}
</nav>
diff --git a/ui/src/components/VersionsView.svelte b/ui/src/components/VersionsView.svelte
index ce354b9..d4d36d5 100644
--- a/ui/src/components/VersionsView.svelte
+++ b/ui/src/components/VersionsView.svelte
@@ -1,6 +1,8 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
+ import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { gameState } from "../stores/game.svelte";
+ import { instancesState } from "../stores/instances.svelte";
import ModLoaderSelector from "./ModLoaderSelector.svelte";
let searchQuery = $state("");
@@ -9,40 +11,139 @@
);
// Filter by version type
- let typeFilter = $state<"all" | "release" | "snapshot" | "modded">("all");
+ let typeFilter = $state<"all" | "release" | "snapshot" | "installed">("all");
- // Installed modded versions
- let installedFabricVersions = $state<string[]>([]);
+ // Installed modded versions with Java version info (Fabric + Forge)
+ let installedFabricVersions = $state<Array<{ id: string; javaVersion?: number }>>([]);
let isLoadingModded = $state(false);
- // Load installed modded versions
+ // Load installed modded versions with Java version info (both Fabric and Forge)
async function loadInstalledModdedVersions() {
+ if (!instancesState.activeInstanceId) {
+ installedFabricVersions = [];
+ isLoadingModded = false;
+ return;
+ }
isLoadingModded = true;
try {
- installedFabricVersions = await invoke<string[]>(
- "list_installed_fabric_versions"
+ // Get all installed versions and filter for modded ones (Fabric and Forge)
+ const allInstalled = await invoke<Array<{ id: string; type: string }>>(
+ "list_installed_versions",
+ { instanceId: instancesState.activeInstanceId }
);
+
+ // Filter for Fabric and Forge versions
+ const moddedIds = allInstalled
+ .filter(v => v.type === "fabric" || v.type === "forge")
+ .map(v => v.id);
+
+ // Load Java version for each installed modded version
+ const versionsWithJava = await Promise.all(
+ moddedIds.map(async (id) => {
+ try {
+ const javaVersion = await invoke<number | null>(
+ "get_version_java_version",
+ {
+ instanceId: instancesState.activeInstanceId!,
+ versionId: id,
+ }
+ );
+ return {
+ id,
+ javaVersion: javaVersion ?? undefined,
+ };
+ } catch (e) {
+ console.error(`Failed to get Java version for ${id}:`, e);
+ return { id, javaVersion: undefined };
+ }
+ })
+ );
+
+ installedFabricVersions = versionsWithJava;
} catch (e) {
- console.error("Failed to load installed fabric versions:", e);
+ console.error("Failed to load installed modded versions:", e);
} finally {
isLoadingModded = false;
}
}
- // Load on mount
+ let versionDeletedUnlisten: UnlistenFn | null = null;
+ let downloadCompleteUnlisten: UnlistenFn | null = null;
+ let versionInstalledUnlisten: UnlistenFn | null = null;
+ let fabricInstalledUnlisten: UnlistenFn | null = null;
+ let forgeInstalledUnlisten: UnlistenFn | null = null;
+
+ // Load on mount and setup event listeners
$effect(() => {
loadInstalledModdedVersions();
+ setupEventListeners();
+ return () => {
+ if (versionDeletedUnlisten) {
+ versionDeletedUnlisten();
+ }
+ if (downloadCompleteUnlisten) {
+ downloadCompleteUnlisten();
+ }
+ if (versionInstalledUnlisten) {
+ versionInstalledUnlisten();
+ }
+ if (fabricInstalledUnlisten) {
+ fabricInstalledUnlisten();
+ }
+ if (forgeInstalledUnlisten) {
+ forgeInstalledUnlisten();
+ }
+ };
});
+ async function setupEventListeners() {
+ // Refresh versions when a version is deleted
+ versionDeletedUnlisten = await listen("version-deleted", async () => {
+ await gameState.loadVersions();
+ await loadInstalledModdedVersions();
+ });
+
+ // Refresh versions when a download completes (version installed)
+ downloadCompleteUnlisten = await listen("download-complete", async () => {
+ await gameState.loadVersions();
+ await loadInstalledModdedVersions();
+ });
+
+ // Refresh when a version is installed
+ versionInstalledUnlisten = await listen("version-installed", async () => {
+ await gameState.loadVersions();
+ await loadInstalledModdedVersions();
+ });
+
+ // Refresh when Fabric is installed
+ fabricInstalledUnlisten = await listen("fabric-installed", async () => {
+ await gameState.loadVersions();
+ await loadInstalledModdedVersions();
+ });
+
+ // Refresh when Forge is installed
+ forgeInstalledUnlisten = await listen("forge-installed", async () => {
+ await gameState.loadVersions();
+ await loadInstalledModdedVersions();
+ });
+ }
+
// Combined versions list (vanilla + modded)
let allVersions = $derived(() => {
- const moddedVersions = installedFabricVersions.map((id) => ({
- id,
- type: "fabric",
- url: "",
- time: "",
- releaseTime: new Date().toISOString(),
- }));
+ const moddedVersions = installedFabricVersions.map((v) => {
+ // Determine type based on version ID
+ const versionType = v.id.startsWith("fabric-loader-") ? "fabric" :
+ v.id.includes("-forge-") ? "forge" : "fabric";
+ return {
+ id: v.id,
+ type: versionType,
+ url: "",
+ time: "",
+ releaseTime: new Date().toISOString(),
+ javaVersion: v.javaVersion,
+ isInstalled: true, // Modded versions in the list are always installed
+ };
+ });
return [...moddedVersions, ...gameState.versions];
});
@@ -54,10 +155,8 @@
versions = versions.filter((v) => v.type === "release");
} else if (typeFilter === "snapshot") {
versions = versions.filter((v) => v.type === "snapshot");
- } else if (typeFilter === "modded") {
- versions = versions.filter(
- (v) => v.type === "fabric" || v.type === "forge"
- );
+ } else if (typeFilter === "installed") {
+ versions = versions.filter((v) => v.isInstalled === true);
}
// Apply search filter
@@ -90,10 +189,90 @@
function handleModLoaderInstall(versionId: string) {
// Refresh the installed versions list
loadInstalledModdedVersions();
+ // Refresh vanilla versions to update isInstalled status
+ gameState.loadVersions();
// Select the newly installed version
gameState.selectedVersion = versionId;
}
+ // Delete confirmation dialog state
+ let showDeleteDialog = $state(false);
+ let versionToDelete = $state<string | null>(null);
+
+ // Show delete confirmation dialog
+ function showDeleteConfirmation(versionId: string, event: MouseEvent) {
+ event.stopPropagation(); // Prevent version selection
+ versionToDelete = versionId;
+ showDeleteDialog = true;
+ }
+
+ // Cancel delete
+ function cancelDelete() {
+ showDeleteDialog = false;
+ versionToDelete = null;
+ }
+
+ // Confirm and delete version
+ async function confirmDelete() {
+ if (!versionToDelete) return;
+
+ try {
+ await invoke("delete_version", { versionId: versionToDelete });
+ // Clear selection if deleted version was selected
+ if (gameState.selectedVersion === versionToDelete) {
+ gameState.selectedVersion = "";
+ }
+ // Close dialog
+ showDeleteDialog = false;
+ versionToDelete = null;
+ // Versions will be refreshed automatically via event listener
+ } catch (e) {
+ console.error("Failed to delete version:", e);
+ alert(`Failed to delete version: ${e}`);
+ // Keep dialog open on error so user can retry
+ }
+ }
+
+ // Version metadata for the selected version
+ interface VersionMetadata {
+ id: string;
+ javaVersion?: number;
+ isInstalled: boolean;
+ }
+
+ let selectedVersionMetadata = $state<VersionMetadata | null>(null);
+ let isLoadingMetadata = $state(false);
+
+ // Load metadata when version is selected
+ async function loadVersionMetadata(versionId: string) {
+ if (!versionId) {
+ selectedVersionMetadata = null;
+ return;
+ }
+
+ isLoadingMetadata = true;
+ try {
+ const metadata = await invoke<VersionMetadata>("get_version_metadata", {
+ versionId,
+ });
+ selectedVersionMetadata = metadata;
+ } catch (e) {
+ console.error("Failed to load version metadata:", e);
+ selectedVersionMetadata = null;
+ } finally {
+ isLoadingMetadata = false;
+ }
+ }
+
+ // Watch for selected version changes
+ $effect(() => {
+ if (gameState.selectedVersion) {
+ loadVersionMetadata(gameState.selectedVersion);
+ } else {
+ selectedVersionMetadata = null;
+ }
+ });
+
// Get the base Minecraft version from selected version (for mod loader selector)
let selectedBaseVersion = $derived(() => {
const selected = gameState.selectedVersion;
@@ -140,7 +319,7 @@
<!-- Type Filter Tabs (Glass Caps) -->
<div class="flex p-1 bg-white/60 dark:bg-black/20 rounded-xl border border-black/5 dark:border-white/5">
- {#each ['all', 'release', 'snapshot', 'modded'] as filter}
+ {#each ['all', 'release', 'snapshot', 'installed'] as filter}
<button
class="flex-1 px-3 py-2 rounded-lg text-sm font-medium transition-all duration-200 capitalize
{typeFilter === filter
@@ -180,29 +359,52 @@
<div class="absolute inset-0 bg-gradient-to-r from-indigo-500/10 to-transparent pointer-events-none"></div>
{/if}
- <div class="relative z-10 flex items-center gap-4">
+ <div class="relative z-10 flex items-center gap-4 flex-1">
<span
class="px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wide border {badge.class}"
>
{badge.text}
</span>
- <div>
+ <div class="flex-1">
<div class="font-bold font-mono text-lg tracking-tight {isSelected ? 'text-black dark:text-white' : 'text-gray-700 dark:text-zinc-300 group-hover:text-black dark:group-hover:text-white'}">
{version.id}
</div>
- {#if version.releaseTime && version.type !== "fabric" && version.type !== "forge"}
- <div class="text-xs dark:text-white/30 text-black/30">
- {new Date(version.releaseTime).toLocaleDateString()}
- </div>
- {/if}
+ <div class="flex items-center gap-2 mt-0.5">
+ {#if version.releaseTime && version.type !== "fabric" && version.type !== "forge"}
+ <div class="text-xs dark:text-white/30 text-black/30">
+ {new Date(version.releaseTime).toLocaleDateString()}
+ </div>
+ {/if}
+ {#if version.javaVersion}
+ <div class="flex items-center gap-1 text-xs dark:text-white/40 text-black/40">
+ <span class="opacity-60">☕</span>
+ <span class="font-medium">Java {version.javaVersion}</span>
+ </div>
+ {/if}
+ </div>
</div>
</div>
- {#if isSelected}
- <div class="relative z-10 text-indigo-500 dark:text-indigo-400">
- <span class="text-lg">Selected</span>
- </div>
- {/if}
+ <div class="relative z-10 flex items-center gap-2">
+ {#if version.isInstalled === true}
+ <button
+ onclick={(e) => showDeleteConfirmation(version.id, e)}
+ class="p-2 rounded-lg text-red-500 dark:text-red-400 hover:bg-red-500/10 dark:hover:bg-red-500/20 transition-colors opacity-0 group-hover:opacity-100"
+ title="Delete version"
+ >
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+ <path d="M3 6h18"></path>
+ <path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
+ <path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
+ </svg>
+ </button>
+ {/if}
+ {#if isSelected}
+ <div class="text-indigo-500 dark:text-indigo-400">
+ <span class="text-lg">Selected</span>
+ </div>
+ {/if}
+ </div>
</button>
{/each}
{/if}
@@ -217,9 +419,50 @@
<h3 class="text-xs font-bold uppercase tracking-widest dark:text-white/40 text-black/40 mb-2 relative z-10">Current Selection</h3>
{#if gameState.selectedVersion}
- <p class="font-mono text-3xl font-black text-transparent bg-clip-text bg-gradient-to-r from-gray-900 to-gray-600 dark:from-white dark:to-white/70 relative z-10 truncate">
+ <p class="font-mono text-3xl font-black text-transparent bg-clip-text bg-gradient-to-r from-gray-900 to-gray-600 dark:from-white dark:to-white/70 relative z-10 truncate mb-4">
{gameState.selectedVersion}
</p>
+
+ <!-- Version Metadata -->
+ {#if isLoadingMetadata}
+ <div class="space-y-3 relative z-10">
+ <div class="animate-pulse space-y-2">
+ <div class="h-4 bg-black/10 dark:bg-white/10 rounded w-3/4"></div>
+ <div class="h-4 bg-black/10 dark:bg-white/10 rounded w-1/2"></div>
+ </div>
+ </div>
+ {:else if selectedVersionMetadata}
+ <div class="space-y-3 relative z-10">
+ <!-- Java Version -->
+ {#if selectedVersionMetadata.javaVersion}
+ <div>
+ <div class="text-[10px] font-bold uppercase tracking-wider dark:text-white/40 text-black/40 mb-1">Java Version</div>
+ <div class="flex items-center gap-2">
+ <span class="text-lg opacity-60">☕</span>
+ <span class="text-sm dark:text-white text-black font-medium">
+ Java {selectedVersionMetadata.javaVersion}
+ </span>
+ </div>
+ </div>
+ {/if}
+
+ <!-- Installation Status -->
+ <div>
+ <div class="text-[10px] font-bold uppercase tracking-wider dark:text-white/40 text-black/40 mb-1">Status</div>
+ <div class="flex items-center gap-2">
+ {#if selectedVersionMetadata.isInstalled === true}
+ <span class="px-2 py-0.5 bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 text-[10px] font-bold rounded border border-emerald-500/30">
+ Installed
+ </span>
+ {:else if selectedVersionMetadata.isInstalled === false}
+ <span class="px-2 py-0.5 bg-zinc-500/20 text-zinc-600 dark:text-zinc-400 text-[10px] font-bold rounded border border-zinc-500/30">
+ Not Installed
+ </span>
+ {/if}
+ </div>
+ </div>
+ </div>
+ {/if}
{:else}
<p class="dark:text-white/20 text-black/20 italic relative z-10">None selected</p>
{/if}
@@ -235,5 +478,30 @@
</div>
</div>
-</div>
+ <!-- Delete Version Confirmation Dialog -->
+ {#if showDeleteDialog && versionToDelete}
+ <div class="fixed inset-0 z-[200] bg-black/70 dark:bg-black/80 backdrop-blur-sm flex items-center justify-center p-4">
+ <div class="bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-700 rounded-xl shadow-2xl p-6 max-w-sm w-full animate-in fade-in zoom-in-95 duration-200">
+ <h3 class="text-lg font-bold text-gray-900 dark:text-white mb-2">Delete Version</h3>
+ <p class="text-zinc-600 dark:text-zinc-400 text-sm mb-6">
+ Are you sure you want to delete version <span class="text-gray-900 dark:text-white font-mono font-medium">{versionToDelete}</span>? This action cannot be undone.
+ </p>
+ <div class="flex gap-3 justify-end">
+ <button
+ onclick={cancelDelete}
+ class="px-4 py-2 text-sm font-medium text-zinc-600 dark:text-zinc-300 hover:text-zinc-900 dark:hover:text-white bg-zinc-100 dark:bg-zinc-800 hover:bg-zinc-200 dark:hover:bg-zinc-700 rounded-lg transition-colors"
+ >
+ Cancel
+ </button>
+ <button
+ onclick={confirmDelete}
+ class="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-500 rounded-lg transition-colors"
+ >
+ Delete
+ </button>
+ </div>
+ </div>
+ </div>
+ {/if}
+</div>
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();
diff --git a/ui/src/types/index.ts b/ui/src/types/index.ts
index 83e7f9e..a5b336e 100644
--- a/ui/src/types/index.ts
+++ b/ui/src/types/index.ts
@@ -1,4 +1,4 @@
-export type ViewType = "home" | "versions" | "settings";
+export type ViewType = "home" | "versions" | "settings" | "guide" | "instances";
export interface Version {
id: string;
@@ -6,6 +6,8 @@ export interface Version {
url: string;
time: string;
releaseTime: string;
+ javaVersion?: number; // Java major version requirement (e.g., 8, 17, 21)
+ isInstalled?: boolean; // Whether this version is installed locally
}
export interface Account {
@@ -26,6 +28,31 @@ export interface DeviceCodeResponse {
message?: string;
}
+export interface AssistantConfig {
+ enabled: boolean;
+ llm_provider: "ollama" | "openai";
+ // Ollama settings
+ ollama_endpoint: string;
+ ollama_model: string;
+ // OpenAI settings
+ openai_api_key?: string;
+ openai_endpoint: string;
+ openai_model: string;
+ // Common settings
+ system_prompt: string;
+ response_language: string;
+ // TTS settings
+ tts_enabled: boolean;
+ tts_provider: string;
+}
+
+export interface ModelInfo {
+ id: string;
+ name: string;
+ size?: string;
+ details?: string;
+}
+
export interface LauncherConfig {
min_memory: number;
max_memory: number;
@@ -40,6 +67,7 @@ export interface LauncherConfig {
theme: string;
log_upload_service: "paste.rs" | "pastebin.com";
pastebin_api_key?: string;
+ assistant: AssistantConfig;
}
export interface JavaInstallation {
@@ -159,3 +187,18 @@ export interface InstalledForgeVersion {
// ==================== Mod Loader Type ====================
export type ModLoaderType = "vanilla" | "fabric" | "forge";
+
+// ==================== Instance Types ====================
+
+export interface Instance {
+ id: string;
+ name: string;
+ game_dir: string;
+ version_id?: string;
+ created_at: number;
+ last_played?: number;
+ icon_path?: string;
+ notes?: string;
+ mod_loader?: string;
+ mod_loader_version?: string;
+}