diff options
Diffstat (limited to 'ui/src')
| -rw-r--r-- | ui/src/App.svelte | 21 | ||||
| -rw-r--r-- | ui/src/assets/svelte.svg | 2 | ||||
| -rw-r--r-- | ui/src/components/AssistantView.svelte | 436 | ||||
| -rw-r--r-- | ui/src/components/ConfigEditorModal.svelte | 369 | ||||
| -rw-r--r-- | ui/src/components/CustomSelect.svelte | 43 | ||||
| -rw-r--r-- | ui/src/components/SettingsView.svelte | 386 | ||||
| -rw-r--r-- | ui/src/components/Sidebar.svelte | 5 | ||||
| -rw-r--r-- | ui/src/components/VersionsView.svelte | 1 | ||||
| -rw-r--r-- | ui/src/stores/assistant.svelte.ts | 166 | ||||
| -rw-r--r-- | ui/src/stores/logs.svelte.ts | 8 | ||||
| -rw-r--r-- | ui/src/stores/settings.svelte.ts | 167 | ||||
| -rw-r--r-- | ui/src/types/index.ts | 28 |
12 files changed, 1611 insertions, 21 deletions
diff --git a/ui/src/App.svelte b/ui/src/App.svelte index 760a15f..2b78892 100644 --- a/ui/src/App.svelte +++ b/ui/src/App.svelte @@ -10,6 +10,7 @@ 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 Sidebar from "./components/Sidebar.svelte"; import StatusToast from "./components/StatusToast.svelte"; import VersionsView from "./components/VersionsView.svelte"; @@ -18,6 +19,7 @@ import { gameState } from "./stores/game.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 +31,19 @@ } 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(); 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') @@ -120,6 +117,8 @@ <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/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/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..3d36f89 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 } 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 @@ -77,6 +77,7 @@ {@render navItem('home', Home, 'Overview')} {@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..063c28d 100644 --- a/ui/src/components/VersionsView.svelte +++ b/ui/src/components/VersionsView.svelte @@ -236,4 +236,3 @@ </div> </div> </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/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..6471869 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"; export interface Version { id: string; @@ -26,6 +26,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 +65,7 @@ export interface LauncherConfig { theme: string; log_upload_service: "paste.rs" | "pastebin.com"; pastebin_api_key?: string; + assistant: AssistantConfig; } export interface JavaInstallation { |