ui: persist playground state across route navigation (#525)

- Keep Playground component mounted when navigating away, preserving
streaming/generating state
- Add animated gradient effect on Playground nav link when activity is
in progress
This commit is contained in:
Benson Wong
2026-02-15 21:30:52 -08:00
committed by GitHub
parent 3e52144058
commit e3bf065574
11 changed files with 136 additions and 66 deletions
+48
View File
@@ -0,0 +1,48 @@
## Project Description:
llama-swap is a light weight, transparent proxy server that provides automatic model swapping to llama.cpp's server.
## Tech stack
- golang
- typescript, vite and svelt5 for UI (located in ui/)
## Workflow Tasks
- when summarizing changes only include details that require further action
- just say "Done." when there is no further action
- use `gh` to create PRs and load issues
- keep PR descriptions short and focused on changes.
- never include a test plan
## Testing
- Follow test naming conventions like `TestProxyManager_<test name>`, `TestProcessGroup_<test name>`, etc.
- Use `go test -v -run <name pattern for new tests>` to run any new tests you've written.
- Use `make test-dev` after running new tests for a quick over all test run. This runs `go test` and `staticcheck`. Fix any static checking errors. Use this only when changes are made to any code under the `proxy/` directory
- Use `make test-all` before completing work. This includes long running concurrency tests.
### Commit message example format:
```
proxy: add new feature
Add new feature that implements functionality X and Y.
- key change 1
- key change 2
- key change 3
fixes #123
```
## Code Reviews
- use three levels High, Medium, Low severity
- label each discovered issue with a label like H1, M2, L3 respectively
- High severity are must fix issues (security, race conditions, critical bugs)
- Medium severity are recommended improvements (coding style, missing functionality, inconsistencies)
- Low severity are nice to have changes and nits
- Include a suggestion with each discovered item
- Limit your code review to three items with the highest priority first
- Double check your discovered items and recommended remediations
+1 -49
View File
@@ -1,49 +1 @@
## Project Description: @AGENTS.md
llama-swap is a light weight, transparent proxy server that provides automatic model swapping to llama.cpp's server.
## Tech stack
- golang
- typescript, vite and react for UI (located in ui/)
## Workflow Tasks
- when summarizing changes only include details that require further action
- just say "Done." when there is no further action
- use `gh` to create PRs and load issues
- do include Co-Authored-By or created by when committing changes or creating PRs
- keep PR descriptions short and focused on changes.
- never include a test plan
## Testing
- Follow test naming conventions like `TestProxyManager_<test name>`, `TestProcessGroup_<test name>`, etc.
- Use `go test -v -run <name pattern for new tests>` to run any new tests you've written.
- Use `make test-dev` after running new tests for a quick over all test run. This runs `go test` and `staticcheck`. Fix any static checking errors. Use this only when changes are made to any code under the `proxy/` directory
- Use `make test-all` before completing work. This includes long running concurrency tests.
### Commit message example format:
```
proxy: add new feature
Add new feature that implements functionality X and Y.
- key change 1
- key change 2
- key change 3
fixes #123
```
## Code Reviews
- use three levels High, Medium, Low severity
- label each discovered issue with a label like H1, M2, L3 respectively
- High severity are must fix issues (security, race conditions, critical bugs)
- Medium severity are recommended improvements (coding style, missing functionality, inconsistencies)
- Low severity are nice to have changes and nits
- Include a suggestion with each discovered item
- Limit your code review to three items with the highest priority first
- Double check your discovered items and recommended remediations
+15 -5
View File
@@ -6,23 +6,28 @@
import Models from "./routes/Models.svelte"; import Models from "./routes/Models.svelte";
import Activity from "./routes/Activity.svelte"; import Activity from "./routes/Activity.svelte";
import Playground from "./routes/Playground.svelte"; import Playground from "./routes/Playground.svelte";
import PlaygroundStub from "./routes/PlaygroundStub.svelte";
import { enableAPIEvents } from "./stores/api"; import { enableAPIEvents } from "./stores/api";
import { initScreenWidth, isDarkMode, appTitle, connectionState } from "./stores/theme"; import { initScreenWidth, isDarkMode, appTitle, connectionState } from "./stores/theme";
import { currentRoute } from "./stores/route";
const routes = { const routes = {
"/": Playground, "/": PlaygroundStub,
"/models": Models, "/models": Models,
"/logs": LogViewer, "/logs": LogViewer,
"/activity": Activity, "/activity": Activity,
"*": Playground, "*": PlaygroundStub,
}; };
// Sync theme to document attribute function handleRouteLoaded(event: { detail: { route: string | RegExp } }) {
const route = event.detail.route;
currentRoute.set(typeof route === "string" ? route : "/");
}
$effect(() => { $effect(() => {
document.documentElement.setAttribute("data-theme", $isDarkMode ? "dark" : "light"); document.documentElement.setAttribute("data-theme", $isDarkMode ? "dark" : "light");
}); });
// Sync title to document
$effect(() => { $effect(() => {
const icon = $connectionState === "connecting" ? "\u{1F7E1}" : $connectionState === "connected" ? "\u{1F7E2}" : "\u{1F534}"; const icon = $connectionState === "connecting" ? "\u{1F7E1}" : $connectionState === "connected" ? "\u{1F7E2}" : "\u{1F534}";
document.title = `${icon} ${$appTitle}`; document.title = `${icon} ${$appTitle}`;
@@ -43,6 +48,11 @@
<Header /> <Header />
<main class="flex-1 overflow-auto p-4"> <main class="flex-1 overflow-auto p-4">
<Router {routes} /> <div class="h-full" class:hidden={$currentRoute !== "/"}>
<Playground />
</div>
<div class="h-full" class:hidden={$currentRoute === "/"}>
<Router {routes} on:routeLoaded={handleRouteLoaded} />
</div>
</main> </main>
</div> </div>
+30 -8
View File
@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import { link, location } from "svelte-spa-router"; import { link } from "svelte-spa-router";
import { screenWidth, toggleTheme, isDarkMode, appTitle, isNarrow } from "../stores/theme"; import { screenWidth, toggleTheme, isDarkMode, appTitle, isNarrow } from "../stores/theme";
import { currentRoute } from "../stores/route";
import { playgroundActivity } from "../stores/playgroundActivity";
import ConnectionStatus from "./ConnectionStatus.svelte"; import ConnectionStatus from "./ConnectionStatus.svelte";
function handleTitleChange(newTitle: string): void { function handleTitleChange(newTitle: string): void {
@@ -22,9 +24,10 @@
handleTitleChange(target.textContent || "(set title)"); handleTitleChange(target.textContent || "(set title)");
} }
function isActive(path: string, currentLocation: string): boolean { function isActive(path: string, current: string): boolean {
return path === "/" ? currentLocation === "/" : currentLocation.startsWith(path); return path === "/" ? current === "/" : current.startsWith(path);
} }
</script> </script>
<header <header
@@ -47,8 +50,7 @@
<a <a
href="/" href="/"
use:link use:link
class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 whitespace-nowrap" class="p-1 whitespace-nowrap {isActive('/', $currentRoute) ? 'font-semibold' : ''} {$playgroundActivity ? 'activity-link' : 'text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100'}"
class:font-semibold={isActive("/", $location)}
> >
Playground Playground
</a> </a>
@@ -56,7 +58,7 @@
href="/models" href="/models"
use:link use:link
class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 whitespace-nowrap" class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 whitespace-nowrap"
class:font-semibold={isActive("/models", $location)} class:font-semibold={isActive("/models", $currentRoute)}
> >
Models Models
</a> </a>
@@ -64,7 +66,7 @@
href="/activity" href="/activity"
use:link use:link
class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 whitespace-nowrap" class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 whitespace-nowrap"
class:font-semibold={isActive("/activity", $location)} class:font-semibold={isActive("/activity", $currentRoute)}
> >
Activity Activity
</a> </a>
@@ -72,7 +74,7 @@
href="/logs" href="/logs"
use:link use:link
class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 whitespace-nowrap" class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 whitespace-nowrap"
class:font-semibold={isActive("/logs", $location)} class:font-semibold={isActive("/logs", $currentRoute)}
> >
Logs Logs
</a> </a>
@@ -96,3 +98,23 @@
<ConnectionStatus /> <ConnectionStatus />
</menu> </menu>
</header> </header>
<style>
.activity-link {
background: linear-gradient(90deg, #6366f1, #8b5cf6, #a855f7, #8b5cf6, #6366f1);
background-size: 200% 100%;
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
animation: gradient-shift 2s linear infinite;
}
@keyframes gradient-shift {
0% {
background-position: 0% 50%;
}
100% {
background-position: 200% 50%;
}
}
</style>
@@ -2,6 +2,7 @@
import { models } from "../../stores/api"; import { models } from "../../stores/api";
import { persistentStore } from "../../stores/persistent"; import { persistentStore } from "../../stores/persistent";
import { transcribeAudio } from "../../lib/audioApi"; import { transcribeAudio } from "../../lib/audioApi";
import { playgroundStores } from "../../stores/playgroundActivity";
import ModelSelector from "./ModelSelector.svelte"; import ModelSelector from "./ModelSelector.svelte";
const selectedModelStore = persistentStore<string>("playground-audio-model", ""); const selectedModelStore = persistentStore<string>("playground-audio-model", "");
@@ -22,6 +23,10 @@
let canTranscribe = $derived(selectedFile !== null && $selectedModelStore !== "" && !isTranscribing); let canTranscribe = $derived(selectedFile !== null && $selectedModelStore !== "" && !isTranscribing);
$effect(() => {
playgroundStores.audioTranscribing.set(isTranscribing);
});
function validateFile(file: File): { valid: boolean; error?: string } { function validateFile(file: File): { valid: boolean; error?: string } {
const ext = '.' + file.name.split('.').pop()?.toLowerCase(); const ext = '.' + file.name.split('.').pop()?.toLowerCase();
@@ -2,6 +2,7 @@
import { models } from "../../stores/api"; import { models } from "../../stores/api";
import { persistentStore } from "../../stores/persistent"; import { persistentStore } from "../../stores/persistent";
import { streamChatCompletion } from "../../lib/chatApi"; import { streamChatCompletion } from "../../lib/chatApi";
import { playgroundStores } from "../../stores/playgroundActivity";
import type { ChatMessage, ContentPart } from "../../lib/types"; import type { ChatMessage, ContentPart } from "../../lib/types";
import ChatMessageComponent from "./ChatMessage.svelte"; import ChatMessageComponent from "./ChatMessage.svelte";
import ModelSelector from "./ModelSelector.svelte"; import ModelSelector from "./ModelSelector.svelte";
@@ -35,6 +36,10 @@
let hasModels = $derived($models.some((m) => !m.unlisted)); let hasModels = $derived($models.some((m) => !m.unlisted));
let userScrolledUp = $state(false); let userScrolledUp = $state(false);
$effect(() => {
playgroundStores.chatStreaming.set(isStreaming);
});
function handleMessagesScroll() { function handleMessagesScroll() {
if (!messagesContainer) return; if (!messagesContainer) return;
const { scrollTop, scrollHeight, clientHeight } = messagesContainer; const { scrollTop, scrollHeight, clientHeight } = messagesContainer;
@@ -2,6 +2,7 @@
import { models } from "../../stores/api"; import { models } from "../../stores/api";
import { persistentStore } from "../../stores/persistent"; import { persistentStore } from "../../stores/persistent";
import { generateImage } from "../../lib/imageApi"; import { generateImage } from "../../lib/imageApi";
import { playgroundStores } from "../../stores/playgroundActivity";
import ModelSelector from "./ModelSelector.svelte"; import ModelSelector from "./ModelSelector.svelte";
import ExpandableTextarea from "./ExpandableTextarea.svelte"; import ExpandableTextarea from "./ExpandableTextarea.svelte";
@@ -17,6 +18,10 @@
let hasModels = $derived($models.some((m) => !m.unlisted)); let hasModels = $derived($models.some((m) => !m.unlisted));
$effect(() => {
playgroundStores.imageGenerating.set(isGenerating);
});
async function generate() { async function generate() {
const trimmedPrompt = prompt.trim(); const trimmedPrompt = prompt.trim();
if (!trimmedPrompt || !$selectedModelStore || isGenerating) return; if (!trimmedPrompt || !$selectedModelStore || isGenerating) return;
@@ -2,6 +2,7 @@
import { models } from "../../stores/api"; import { models } from "../../stores/api";
import { persistentStore } from "../../stores/persistent"; import { persistentStore } from "../../stores/persistent";
import { generateSpeech } from "../../lib/speechApi"; import { generateSpeech } from "../../lib/speechApi";
import { playgroundStores } from "../../stores/playgroundActivity";
import ModelSelector from "./ModelSelector.svelte"; import ModelSelector from "./ModelSelector.svelte";
import ExpandableTextarea from "./ExpandableTextarea.svelte"; import ExpandableTextarea from "./ExpandableTextarea.svelte";
@@ -20,11 +21,9 @@
let availableVoices = $state<string[]>(["coral", "alloy", "echo", "fable", "onyx", "nova", "shimmer"]); let availableVoices = $state<string[]>(["coral", "alloy", "echo", "fable", "onyx", "nova", "shimmer"]);
let isLoadingVoices = $state(false); let isLoadingVoices = $state(false);
// Default voices to fall back to if API call fails
const defaultVoices = ["coral", "alloy", "echo", "fable", "onyx", "nova", "shimmer"]; const defaultVoices = ["coral", "alloy", "echo", "fable", "onyx", "nova", "shimmer"];
const CACHE_KEY = "playground-speech-voices-cache"; const CACHE_KEY = "playground-speech-voices-cache";
// Load voices cache from localStorage
function getVoicesCache(): Record<string, string[]> { function getVoicesCache(): Record<string, string[]> {
if (typeof window === "undefined") return {}; if (typeof window === "undefined") return {};
try { try {
@@ -35,7 +34,6 @@
} }
} }
// Save voices cache to localStorage
function saveVoicesCache(cache: Record<string, string[]>) { function saveVoicesCache(cache: Record<string, string[]>) {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
try { try {
@@ -47,9 +45,12 @@
let hasModels = $derived($models.some((m) => !m.unlisted)); let hasModels = $derived($models.some((m) => !m.unlisted));
// Track if this is the initial page load to avoid fetching on refresh
let isInitialLoad = $state(true); let isInitialLoad = $state(true);
$effect(() => {
playgroundStores.speechGenerating.set(isGenerating);
});
// On page load, restore cached voices for the selected model if available // On page load, restore cached voices for the selected model if available
$effect(() => { $effect(() => {
const model = $selectedModelStore; const model = $selectedModelStore;
@@ -0,0 +1 @@
<!-- empty: real Playground is always mounted in App.svelte -->
@@ -0,0 +1,18 @@
import { writable, derived } from "svelte/store";
const chatStreaming = writable(false);
const imageGenerating = writable(false);
const speechGenerating = writable(false);
const audioTranscribing = writable(false);
export const playgroundActivity = derived(
[chatStreaming, imageGenerating, speechGenerating, audioTranscribing],
([$chat, $image, $speech, $audio]) => $chat || $image || $speech || $audio
);
export const playgroundStores = {
chatStreaming,
imageGenerating,
speechGenerating,
audioTranscribing,
};
+3
View File
@@ -0,0 +1,3 @@
import { writable } from "svelte/store";
export const currentRoute = writable("/");