aboutsummaryrefslogtreecommitdiffstatshomepage
path: root/ui/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'ui/src/components')
-rw-r--r--ui/src/components/AssistantView.svelte436
-rw-r--r--ui/src/components/ConfigEditorModal.svelte369
-rw-r--r--ui/src/components/CustomSelect.svelte43
-rw-r--r--ui/src/components/SettingsView.svelte386
-rw-r--r--ui/src/components/Sidebar.svelte5
-rw-r--r--ui/src/components/VersionsView.svelte1
6 files changed, 1234 insertions, 6 deletions
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>
-