diff options
Diffstat (limited to 'ui/src/components')
| -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 |
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> - |