forked from wylab/llama-swap
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:
@@ -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 +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
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { writable } from "svelte/store";
|
||||||
|
|
||||||
|
export const currentRoute = writable("/");
|
||||||
Reference in New Issue
Block a user