proxy,ui-svelte: replace old UI with svelte+playground
Replace the legacy React UI with the new Svelte-based one. Introduce a Playground in the UI to quickly test out text, image, text to speech and speech to text models behind llama-swap.
Key Changes
New Svelte UI (ui-svelte/)
- Multi-tab Playground with Chat, Image Generation, Audio Transcription, and Speech interfaces
- Chat: message editing/regeneration, markdown rendering with LaTeX math support, image attachments, code syntax highlighting
- Image: size selector, download/fullscreen viewing
- Audio: transcription with peer support
- Speech: voice caching with manual refresh, download button
- Responsive mobile layout with collapsible navigation
- XSS fixes and accessibility improvements
Proxy Improvements
- Add gzip/brotli compression for UI static assets (proxy/ui_compress.go)
- Add GET /v1/audio/voices?model={model} endpoint for voice listing
- Add peer support for /v1/audio/transcriptions
@@ -28,7 +28,7 @@ jobs:
|
|||||||
uses: actions/cache/restore@v4
|
uses: actions/cache/restore@v4
|
||||||
with:
|
with:
|
||||||
path: ./build
|
path: ./build
|
||||||
key: ${{ runner.os }}-simple-responder-${{ hashFiles('misc/simple-responder/simple-responder.go') }}
|
key: ${{ runner.os }}-simple-responder-${{ hashFiles('cmd/simple-responder/simple-responder.go') }}
|
||||||
|
|
||||||
# necessary for testing proxy/Process swapping
|
# necessary for testing proxy/Process swapping
|
||||||
- name: Create simple-responder
|
- name: Create simple-responder
|
||||||
@@ -43,7 +43,7 @@ jobs:
|
|||||||
uses: actions/cache/save@v4
|
uses: actions/cache/save@v4
|
||||||
with:
|
with:
|
||||||
path: ./build
|
path: ./build
|
||||||
key: ${{ runner.os }}-simple-responder-${{ hashFiles('misc/simple-responder/simple-responder.go') }}
|
key: ${{ runner.os }}-simple-responder-${{ hashFiles('cmd/simple-responder/simple-responder.go') }}
|
||||||
|
|
||||||
- name: Test all
|
- name: Test all
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
@@ -210,6 +210,11 @@ func main() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
r.GET("/v1/audio/voices", func(c *gin.Context) {
|
||||||
|
model := c.Query("model")
|
||||||
|
c.JSON(http.StatusOK, gin.H{"voices": []string{"voice1"}, "model": model})
|
||||||
|
})
|
||||||
|
|
||||||
r.GET("/slow-respond", func(c *gin.Context) {
|
r.GET("/slow-respond", func(c *gin.Context) {
|
||||||
echo := c.Query("echo")
|
echo := c.Query("echo")
|
||||||
delay := c.Query("delay")
|
delay := c.Query("delay")
|
||||||
|
|||||||
@@ -71,11 +71,15 @@ func getTestSimpleResponderConfig(expectedMessage string) config.ModelConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func getTestSimpleResponderConfigPort(expectedMessage string, port int) config.ModelConfig {
|
func getTestSimpleResponderConfigPort(expectedMessage string, port int) config.ModelConfig {
|
||||||
|
// Convert path to forward slashes for cross-platform compatibility
|
||||||
|
// Windows handles forward slashes in paths correctly
|
||||||
|
cmdPath := filepath.ToSlash(simpleResponderPath)
|
||||||
|
|
||||||
// Create a YAML string with just the values we want to set
|
// Create a YAML string with just the values we want to set
|
||||||
yamlStr := fmt.Sprintf(`
|
yamlStr := fmt.Sprintf(`
|
||||||
cmd: '%s --port %d --silent --respond %s'
|
cmd: '%s --port %d --silent --respond %s'
|
||||||
proxy: "http://127.0.0.1:%d"
|
proxy: "http://127.0.0.1:%d"
|
||||||
`, simpleResponderPath, port, expectedMessage, port)
|
`, cmdPath, port, expectedMessage, port)
|
||||||
|
|
||||||
var cfg config.ModelConfig
|
var cfg config.ModelConfig
|
||||||
if err := yaml.Unmarshal([]byte(yamlStr), &cfg); err != nil {
|
if err := yaml.Unmarshal([]byte(yamlStr), &cfg); err != nil {
|
||||||
|
|||||||
@@ -303,6 +303,7 @@ func (pm *ProxyManager) setupGinEngine() {
|
|||||||
// Support audio/speech endpoint
|
// Support audio/speech endpoint
|
||||||
pm.ginEngine.POST("/v1/audio/speech", pm.apiKeyAuth(), pm.proxyInferenceHandler)
|
pm.ginEngine.POST("/v1/audio/speech", pm.apiKeyAuth(), pm.proxyInferenceHandler)
|
||||||
pm.ginEngine.POST("/v1/audio/voices", pm.apiKeyAuth(), pm.proxyInferenceHandler)
|
pm.ginEngine.POST("/v1/audio/voices", pm.apiKeyAuth(), pm.proxyInferenceHandler)
|
||||||
|
pm.ginEngine.GET("/v1/audio/voices", pm.apiKeyAuth(), pm.proxyGETModelHandler)
|
||||||
pm.ginEngine.POST("/v1/audio/transcriptions", pm.apiKeyAuth(), pm.proxyOAIPostFormHandler)
|
pm.ginEngine.POST("/v1/audio/transcriptions", pm.apiKeyAuth(), pm.proxyOAIPostFormHandler)
|
||||||
pm.ginEngine.POST("/v1/images/generations", pm.apiKeyAuth(), pm.proxyInferenceHandler)
|
pm.ginEngine.POST("/v1/images/generations", pm.apiKeyAuth(), pm.proxyInferenceHandler)
|
||||||
pm.ginEngine.POST("/v1/images/edits", pm.apiKeyAuth(), pm.proxyOAIPostFormHandler)
|
pm.ginEngine.POST("/v1/images/edits", pm.apiKeyAuth(), pm.proxyOAIPostFormHandler)
|
||||||
@@ -348,25 +349,35 @@ func (pm *ProxyManager) setupGinEngine() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
pm.proxyLogger.Errorf("Failed to load React filesystem: %v", err)
|
pm.proxyLogger.Errorf("Failed to load React filesystem: %v", err)
|
||||||
} else {
|
} else {
|
||||||
|
// Serve files with compression support under /ui/*
|
||||||
|
// This handler checks for pre-compressed .br and .gz files
|
||||||
|
pm.ginEngine.GET("/ui/*filepath", func(c *gin.Context) {
|
||||||
|
filepath := strings.TrimPrefix(c.Param("filepath"), "/")
|
||||||
|
// Default to index.html for directory-like paths
|
||||||
|
if filepath == "" {
|
||||||
|
filepath = "index.html"
|
||||||
|
}
|
||||||
|
|
||||||
// serve files that exist under /ui/*
|
ServeCompressedFile(reactFS, c.Writer, c.Request, filepath)
|
||||||
pm.ginEngine.StaticFS("/ui", reactFS)
|
})
|
||||||
|
|
||||||
// server SPA for UI under /ui/*
|
// Serve SPA for UI under /ui/* - fallback to index.html for client-side routing
|
||||||
pm.ginEngine.NoRoute(func(c *gin.Context) {
|
pm.ginEngine.NoRoute(func(c *gin.Context) {
|
||||||
if !strings.HasPrefix(c.Request.URL.Path, "/ui") {
|
if !strings.HasPrefix(c.Request.URL.Path, "/ui") {
|
||||||
c.AbortWithStatus(http.StatusNotFound)
|
c.AbortWithStatus(http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
file, err := reactFS.Open("index.html")
|
// Check if this looks like a file request (has extension)
|
||||||
if err != nil {
|
path := c.Request.URL.Path
|
||||||
c.String(http.StatusInternalServerError, err.Error())
|
if strings.Contains(path, ".") && !strings.HasSuffix(path, "/") {
|
||||||
|
// This was likely a file request that wasn't found
|
||||||
|
c.AbortWithStatus(http.StatusNotFound)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer file.Close()
|
|
||||||
http.ServeContent(c.Writer, c.Request, "index.html", time.Now(), file)
|
|
||||||
|
|
||||||
|
// Serve index.html for SPA routing
|
||||||
|
ServeCompressedFile(reactFS, c.Writer, c.Request, "index.html")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -744,15 +755,29 @@ func (pm *ProxyManager) proxyOAIPostFormHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Look for a matching local model first, then check peers
|
||||||
|
var nextHandler func(modelID string, w http.ResponseWriter, r *http.Request) error
|
||||||
|
var useModelName string
|
||||||
|
|
||||||
modelID, found := pm.config.RealModelName(requestedModel)
|
modelID, found := pm.config.RealModelName(requestedModel)
|
||||||
if !found {
|
if found {
|
||||||
pm.sendErrorResponse(c, http.StatusBadRequest, fmt.Sprintf("could not find real modelID for %s", requestedModel))
|
processGroup, err := pm.swapProcessGroup(modelID)
|
||||||
return
|
if err != nil {
|
||||||
|
pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error swapping process group: %s", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
useModelName = pm.config.Models[modelID].UseModelName
|
||||||
|
pm.proxyLogger.Debugf("ProxyManager using local Process for model: %s", requestedModel)
|
||||||
|
nextHandler = processGroup.ProxyRequest
|
||||||
|
} else if pm.peerProxy != nil && pm.peerProxy.HasPeerModel(requestedModel) {
|
||||||
|
pm.proxyLogger.Debugf("ProxyManager using ProxyPeer for model: %s", requestedModel)
|
||||||
|
modelID = requestedModel
|
||||||
|
nextHandler = pm.peerProxy.ProxyRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
processGroup, err := pm.swapProcessGroup(modelID)
|
if nextHandler == nil {
|
||||||
if err != nil {
|
pm.sendErrorResponse(c, http.StatusBadRequest, fmt.Sprintf("could not find suitable handler for %s", requestedModel))
|
||||||
pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error swapping process group: %s", err.Error()))
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -768,8 +793,6 @@ func (pm *ProxyManager) proxyOAIPostFormHandler(c *gin.Context) {
|
|||||||
// If this is the model field and we have a profile, use just the model name
|
// If this is the model field and we have a profile, use just the model name
|
||||||
if key == "model" {
|
if key == "model" {
|
||||||
// # issue #69 allow custom model names to be sent to upstream
|
// # issue #69 allow custom model names to be sent to upstream
|
||||||
useModelName := pm.config.Models[modelID].UseModelName
|
|
||||||
|
|
||||||
if useModelName != "" {
|
if useModelName != "" {
|
||||||
fieldValue = useModelName
|
fieldValue = useModelName
|
||||||
} else {
|
} else {
|
||||||
@@ -839,9 +862,46 @@ func (pm *ProxyManager) proxyOAIPostFormHandler(c *gin.Context) {
|
|||||||
modifiedReq.ContentLength = int64(requestBuffer.Len())
|
modifiedReq.ContentLength = int64(requestBuffer.Len())
|
||||||
|
|
||||||
// Use the modified request for proxying
|
// Use the modified request for proxying
|
||||||
if err := processGroup.ProxyRequest(modelID, c.Writer, modifiedReq); err != nil {
|
if err := nextHandler(modelID, c.Writer, modifiedReq); err != nil {
|
||||||
pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error proxying request: %s", err.Error()))
|
pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error proxying request: %s", err.Error()))
|
||||||
pm.proxyLogger.Errorf("Error Proxying Request for processGroup %s and model %s", processGroup.id, modelID)
|
pm.proxyLogger.Errorf("Error Proxying Request for model %s", modelID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pm *ProxyManager) proxyGETModelHandler(c *gin.Context) {
|
||||||
|
requestedModel := c.Query("model")
|
||||||
|
if requestedModel == "" {
|
||||||
|
pm.sendErrorResponse(c, http.StatusBadRequest, "missing required 'model' query parameter")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var nextHandler func(modelID string, w http.ResponseWriter, r *http.Request) error
|
||||||
|
var modelID string
|
||||||
|
|
||||||
|
if realModelID, found := pm.config.RealModelName(requestedModel); found {
|
||||||
|
processGroup, err := pm.swapProcessGroup(realModelID)
|
||||||
|
if err != nil {
|
||||||
|
pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error swapping process group: %s", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
modelID = realModelID
|
||||||
|
pm.proxyLogger.Debugf("ProxyManager using local Process for model: %s", requestedModel)
|
||||||
|
nextHandler = processGroup.ProxyRequest
|
||||||
|
} else if pm.peerProxy != nil && pm.peerProxy.HasPeerModel(requestedModel) {
|
||||||
|
modelID = requestedModel
|
||||||
|
pm.proxyLogger.Debugf("ProxyManager using ProxyPeer for model: %s", requestedModel)
|
||||||
|
nextHandler = pm.peerProxy.ProxyRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
if nextHandler == nil {
|
||||||
|
pm.sendErrorResponse(c, http.StatusBadRequest, fmt.Sprintf("could not find suitable handler for %s", requestedModel))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := nextHandler(modelID, c.Writer, c.Request); err != nil {
|
||||||
|
pm.sendErrorResponse(c, http.StatusInternalServerError, fmt.Sprintf("error proxying request: %s", err.Error()))
|
||||||
|
pm.proxyLogger.Errorf("Error Proxying GET Request for model %s", modelID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -850,6 +850,43 @@ func TestProxyManager_UseModelName(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProxyManager_AudioVoicesGETHandler(t *testing.T) {
|
||||||
|
conf := config.AddDefaultGroupToConfig(config.Config{
|
||||||
|
HealthCheckTimeout: 15,
|
||||||
|
Models: map[string]config.ModelConfig{
|
||||||
|
"model1": getTestSimpleResponderConfig("model1"),
|
||||||
|
},
|
||||||
|
LogLevel: "error",
|
||||||
|
})
|
||||||
|
|
||||||
|
proxy := New(conf)
|
||||||
|
defer proxy.StopProcesses(StopWaitForInflightRequest)
|
||||||
|
|
||||||
|
t.Run("successful GET with model query param", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("GET", "/v1/audio/voices?model=model1", nil)
|
||||||
|
w := CreateTestResponseRecorder()
|
||||||
|
proxy.ServeHTTP(w, req)
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(), "voice1")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing model query param returns 400", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("GET", "/v1/audio/voices", nil)
|
||||||
|
w := CreateTestResponseRecorder()
|
||||||
|
proxy.ServeHTTP(w, req)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(), "missing required 'model' query parameter")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unknown model returns 400", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest("GET", "/v1/audio/voices?model=nonexistent", nil)
|
||||||
|
w := CreateTestResponseRecorder()
|
||||||
|
proxy.ServeHTTP(w, req)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(), "could not find suitable handler")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestProxyManager_CORSOptionsHandler(t *testing.T) {
|
func TestProxyManager_CORSOptionsHandler(t *testing.T) {
|
||||||
config := config.AddDefaultGroupToConfig(config.Config{
|
config := config.AddDefaultGroupToConfig(config.Config{
|
||||||
HealthCheckTimeout: 15,
|
HealthCheckTimeout: 15,
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// selectEncoding chooses the best encoding based on Accept-Encoding header
|
||||||
|
// Returns the encoding ("br", "gzip", or "") and the corresponding file extension
|
||||||
|
func selectEncoding(acceptEncoding string) (encoding, ext string) {
|
||||||
|
if acceptEncoding == "" {
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, part := range strings.Split(acceptEncoding, ",") {
|
||||||
|
enc := strings.TrimSpace(strings.SplitN(part, ";", 2)[0])
|
||||||
|
if enc == "br" {
|
||||||
|
return "br", ".br"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, part := range strings.Split(acceptEncoding, ",") {
|
||||||
|
enc := strings.TrimSpace(strings.SplitN(part, ";", 2)[0])
|
||||||
|
if enc == "gzip" {
|
||||||
|
return "gzip", ".gz"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeCompressedFile serves a file with compression support.
|
||||||
|
// It checks for pre-compressed versions and serves them with proper headers.
|
||||||
|
func ServeCompressedFile(fs http.FileSystem, w http.ResponseWriter, r *http.Request, name string) {
|
||||||
|
encoding, ext := selectEncoding(r.Header.Get("Accept-Encoding"))
|
||||||
|
|
||||||
|
// Try to serve compressed version if client supports it
|
||||||
|
if encoding != "" {
|
||||||
|
if cf, err := fs.Open(name + ext); err == nil {
|
||||||
|
defer cf.Close()
|
||||||
|
|
||||||
|
// Verify it's a regular file (not a directory)
|
||||||
|
if stat, err := cf.Stat(); err == nil && !stat.IsDir() {
|
||||||
|
// Set the content encoding header
|
||||||
|
w.Header().Set("Content-Encoding", encoding)
|
||||||
|
w.Header().Add("Vary", "Accept-Encoding")
|
||||||
|
|
||||||
|
// Get original file info for content type detection
|
||||||
|
origFile, err := fs.Open(name)
|
||||||
|
if err == nil {
|
||||||
|
origFile.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve the compressed file
|
||||||
|
http.ServeContent(w, r, name, stat.ModTime(), cf)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to serving the uncompressed file
|
||||||
|
file, err := fs.Open(name)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
stat, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if stat.IsDir() {
|
||||||
|
http.Error(w, "is a directory", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.ServeContent(w, r, name, stat.ModTime(), file)
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestServeCompressedFile_Brotli(t *testing.T) {
|
||||||
|
// Create test content
|
||||||
|
content := []byte("This is test content that should be compressed with brotli")
|
||||||
|
brContent := []byte("fake-brotli-compressed-data")
|
||||||
|
|
||||||
|
// Create a test filesystem
|
||||||
|
mapFS := fstest.MapFS{
|
||||||
|
"test.js": {Data: content, ModTime: time.Now()},
|
||||||
|
"test.js.br": {Data: brContent, ModTime: time.Now()},
|
||||||
|
"test.js.gz": {Data: []byte("fake-gzip-data"), ModTime: time.Now()},
|
||||||
|
}
|
||||||
|
fs := http.FS(mapFS)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/test.js", nil)
|
||||||
|
req.Header.Set("Accept-Encoding", "br, gzip")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ServeCompressedFile(fs, w, req, "test.js")
|
||||||
|
|
||||||
|
resp := w.Result()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("Expected status 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that brotli is used (preferred over gzip)
|
||||||
|
if encoding := resp.Header.Get("Content-Encoding"); encoding != "br" {
|
||||||
|
t.Errorf("Expected Content-Encoding 'br', got '%s'", encoding)
|
||||||
|
}
|
||||||
|
|
||||||
|
if vary := resp.Header.Get("Vary"); vary != "Accept-Encoding" {
|
||||||
|
t.Errorf("Expected Vary 'Accept-Encoding', got '%s'", vary)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(body, brContent) {
|
||||||
|
t.Errorf("Expected brotli content, got %s", string(body))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeCompressedFile_Gzip(t *testing.T) {
|
||||||
|
// Create test content
|
||||||
|
content := []byte("This is test content that should be compressed with gzip")
|
||||||
|
gzContent := []byte("fake-gzip-compressed-data")
|
||||||
|
|
||||||
|
// Create a test filesystem without brotli
|
||||||
|
mapFS := fstest.MapFS{
|
||||||
|
"test.js": {Data: content, ModTime: time.Now()},
|
||||||
|
"test.js.gz": {Data: gzContent, ModTime: time.Now()},
|
||||||
|
}
|
||||||
|
fs := http.FS(mapFS)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/test.js", nil)
|
||||||
|
req.Header.Set("Accept-Encoding", "gzip")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ServeCompressedFile(fs, w, req, "test.js")
|
||||||
|
|
||||||
|
resp := w.Result()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("Expected status 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if encoding := resp.Header.Get("Content-Encoding"); encoding != "gzip" {
|
||||||
|
t.Errorf("Expected Content-Encoding 'gzip', got '%s'", encoding)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(body, gzContent) {
|
||||||
|
t.Errorf("Expected gzip content, got %s", string(body))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeCompressedFile_UncompressedFallback(t *testing.T) {
|
||||||
|
// Create test content
|
||||||
|
content := []byte("This is uncompressed test content")
|
||||||
|
|
||||||
|
// Create a test filesystem without compressed versions
|
||||||
|
mapFS := fstest.MapFS{
|
||||||
|
"test.js": {Data: content, ModTime: time.Now()},
|
||||||
|
}
|
||||||
|
fs := http.FS(mapFS)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/test.js", nil)
|
||||||
|
req.Header.Set("Accept-Encoding", "br, gzip")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ServeCompressedFile(fs, w, req, "test.js")
|
||||||
|
|
||||||
|
resp := w.Result()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("Expected status 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should not have Content-Encoding header since we're serving uncompressed
|
||||||
|
if encoding := resp.Header.Get("Content-Encoding"); encoding != "" {
|
||||||
|
t.Errorf("Expected no Content-Encoding, got '%s'", encoding)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(body, content) {
|
||||||
|
t.Errorf("Expected original content, got %s", string(body))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeCompressedFile_NoAcceptEncoding(t *testing.T) {
|
||||||
|
// Create test content
|
||||||
|
content := []byte("This is test content")
|
||||||
|
|
||||||
|
// Create a test filesystem with compressed versions
|
||||||
|
mapFS := fstest.MapFS{
|
||||||
|
"test.js": {Data: content, ModTime: time.Now()},
|
||||||
|
"test.js.br": {Data: []byte("brotli"), ModTime: time.Now()},
|
||||||
|
"test.js.gz": {Data: []byte("gzip"), ModTime: time.Now()},
|
||||||
|
}
|
||||||
|
fs := http.FS(mapFS)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/test.js", nil)
|
||||||
|
// No Accept-Encoding header
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ServeCompressedFile(fs, w, req, "test.js")
|
||||||
|
|
||||||
|
resp := w.Result()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Errorf("Expected status 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should serve uncompressed content
|
||||||
|
if encoding := resp.Header.Get("Content-Encoding"); encoding != "" {
|
||||||
|
t.Errorf("Expected no Content-Encoding, got '%s'", encoding)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.Equal(body, content) {
|
||||||
|
t.Errorf("Expected original content, got %s", string(body))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeCompressedFile_NotFound(t *testing.T) {
|
||||||
|
mapFS := fstest.MapFS{}
|
||||||
|
fs := http.FS(mapFS)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/nonexistent.js", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ServeCompressedFile(fs, w, req, "nonexistent.js")
|
||||||
|
|
||||||
|
resp := w.Result()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusNotFound {
|
||||||
|
t.Errorf("Expected status 404, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectEncoding(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
acceptEncoding string
|
||||||
|
wantEncoding string
|
||||||
|
wantExt string
|
||||||
|
}{
|
||||||
|
{"br, gzip", "br", ".br"},
|
||||||
|
{"gzip, deflate", "gzip", ".gz"},
|
||||||
|
{"gzip", "gzip", ".gz"},
|
||||||
|
{"br", "br", ".br"},
|
||||||
|
{"", "", ""},
|
||||||
|
{"deflate", "", ""},
|
||||||
|
{"br;q=1.0, gzip;q=0.5", "br", ".br"},
|
||||||
|
{"gzip;q=1.0, br;q=0.5", "br", ".br"},
|
||||||
|
{"browser", "", ""},
|
||||||
|
{"compress, deflate", "", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
gotEncoding, gotExt := selectEncoding(tt.acceptEncoding)
|
||||||
|
if gotEncoding != tt.wantEncoding || gotExt != tt.wantExt {
|
||||||
|
t.Errorf("selectEncoding(%q) = (%q, %q), want (%q, %q)",
|
||||||
|
tt.acceptEncoding, gotEncoding, gotExt, tt.wantEncoding, tt.wantExt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test with actual pre-compressed files from ui_dist
|
||||||
|
func TestServeCompressedFile_RealFiles(t *testing.T) {
|
||||||
|
// Check if ui_dist exists
|
||||||
|
if _, err := os.Stat("./ui_dist"); os.IsNotExist(err) {
|
||||||
|
t.Skip("ui_dist not found, skipping real file test")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find a .js or .css file that has compressed versions
|
||||||
|
entries, err := os.ReadDir("./ui_dist/assets")
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("Could not read ui_dist/assets: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var testFile string
|
||||||
|
for _, entry := range entries {
|
||||||
|
name := entry.Name()
|
||||||
|
if strings.HasSuffix(name, ".js") && !strings.HasSuffix(name, ".js.gz") && !strings.HasSuffix(name, ".js.br") {
|
||||||
|
// Check if compressed versions exist
|
||||||
|
base := strings.TrimSuffix(name, ".js")
|
||||||
|
if _, err := os.Stat(filepath.Join("./ui_dist/assets", base+".js.gz")); err == nil {
|
||||||
|
testFile = "assets/" + name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if testFile == "" {
|
||||||
|
t.Skip("No suitable test file found with compressed versions")
|
||||||
|
}
|
||||||
|
|
||||||
|
fs := http.FS(os.DirFS("./ui_dist"))
|
||||||
|
|
||||||
|
// Test brotli
|
||||||
|
t.Run("brotli", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/"+testFile, nil)
|
||||||
|
req.Header.Set("Accept-Encoding", "br")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ServeCompressedFile(fs, w, req, testFile)
|
||||||
|
|
||||||
|
resp := w.Result()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("Expected status 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if encoding := resp.Header.Get("Content-Encoding"); encoding != "br" {
|
||||||
|
t.Errorf("Expected Content-Encoding 'br', got '%s'", encoding)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test gzip
|
||||||
|
t.Run("gzip", func(t *testing.T) {
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/"+testFile, nil)
|
||||||
|
req.Header.Set("Accept-Encoding", "gzip")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
ServeCompressedFile(fs, w, req, testFile)
|
||||||
|
|
||||||
|
resp := w.Result()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("Expected status 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if encoding := resp.Header.Get("Content-Encoding"); encoding != "gzip" {
|
||||||
|
t.Errorf("Expected Content-Encoding 'gzip', got '%s'", encoding)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify it's valid gzip
|
||||||
|
reader, err := gzip.NewReader(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Expected valid gzip content: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
// Just read to verify it's valid
|
||||||
|
_, err = io.Copy(io.Discard, reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Failed to decompress gzip: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -7,19 +7,36 @@
|
|||||||
"start": "vite",
|
"start": "vite",
|
||||||
"build": "vite build --emptyOutDir",
|
"build": "vite build --emptyOutDir",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"check": "svelte-check --tsconfig ./tsconfig.json"
|
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||||
"@tailwindcss/vite": "^4.1.8",
|
"@tailwindcss/vite": "^4.1.8",
|
||||||
"@tsconfig/svelte": "^5.0.4",
|
"@tsconfig/svelte": "^5.0.4",
|
||||||
|
"@types/hast": "^3.0.4",
|
||||||
|
"@types/node": "^25.1.0",
|
||||||
"svelte": "^5.19.0",
|
"svelte": "^5.19.0",
|
||||||
"svelte-check": "^4.1.4",
|
"svelte-check": "^4.1.4",
|
||||||
"tailwindcss": "^4.1.8",
|
"tailwindcss": "^4.1.8",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"vite": "^6.3.5"
|
"vite": "^6.3.5",
|
||||||
|
"vite-plugin-compression2": "^2.4.0",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"svelte-spa-router": "^4.0.1"
|
"highlight.js": "^11.11.1",
|
||||||
|
"katex": "^0.16.28",
|
||||||
|
"lucide-svelte": "^0.563.0",
|
||||||
|
"rehype-katex": "^7.0.1",
|
||||||
|
"rehype-stringify": "^10.0.1",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
|
"remark-math": "^6.0.0",
|
||||||
|
"remark-parse": "^11.0.0",
|
||||||
|
"remark-rehype": "^11.1.2",
|
||||||
|
"svelte-spa-router": "^4.0.1",
|
||||||
|
"unified": "^11.0.5",
|
||||||
|
"unist-util-visit": "^5.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,16 @@
|
|||||||
import LogViewer from "./routes/LogViewer.svelte";
|
import LogViewer from "./routes/LogViewer.svelte";
|
||||||
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 { 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";
|
||||||
|
|
||||||
const routes = {
|
const routes = {
|
||||||
"/": Models,
|
"/": Playground,
|
||||||
|
"/models": Models,
|
||||||
"/logs": LogViewer,
|
"/logs": LogViewer,
|
||||||
"/activity": Activity,
|
"/activity": Activity,
|
||||||
"*": Models,
|
"*": Playground,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sync theme to document attribute
|
// Sync theme to document attribute
|
||||||
|
|||||||
@@ -43,19 +43,27 @@
|
|||||||
</h1>
|
</h1>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<menu class="flex items-center gap-4">
|
<menu class="flex items-center gap-4 overflow-x-auto">
|
||||||
<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"
|
class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 whitespace-nowrap"
|
||||||
class:font-semibold={isActive("/", $location)}
|
class:font-semibold={isActive("/", $location)}
|
||||||
|
>
|
||||||
|
Playground
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="/models"
|
||||||
|
use:link
|
||||||
|
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)}
|
||||||
>
|
>
|
||||||
Models
|
Models
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
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"
|
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", $location)}
|
||||||
>
|
>
|
||||||
Activity
|
Activity
|
||||||
@@ -63,7 +71,7 @@
|
|||||||
<a
|
<a
|
||||||
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"
|
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", $location)}
|
||||||
>
|
>
|
||||||
Logs
|
Logs
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { models } from "../../stores/api";
|
||||||
|
import { persistentStore } from "../../stores/persistent";
|
||||||
|
import { transcribeAudio } from "../../lib/audioApi";
|
||||||
|
import ModelSelector from "./ModelSelector.svelte";
|
||||||
|
|
||||||
|
const selectedModelStore = persistentStore<string>("playground-audio-model", "");
|
||||||
|
|
||||||
|
let selectedFile = $state<File | null>(null);
|
||||||
|
let isTranscribing = $state(false);
|
||||||
|
let transcriptionResult = $state<string | null>(null);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let abortController = $state<AbortController | null>(null);
|
||||||
|
let isDragging = $state(false);
|
||||||
|
let fileInput = $state<HTMLInputElement | null>(null);
|
||||||
|
let copied = $state(false);
|
||||||
|
|
||||||
|
const ACCEPTED_FORMATS = ['.mp3', '.wav'];
|
||||||
|
const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25MB
|
||||||
|
|
||||||
|
let hasModels = $derived($models.some((m) => !m.unlisted));
|
||||||
|
|
||||||
|
let canTranscribe = $derived(selectedFile !== null && $selectedModelStore !== "" && !isTranscribing);
|
||||||
|
|
||||||
|
function validateFile(file: File): { valid: boolean; error?: string } {
|
||||||
|
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
|
||||||
|
|
||||||
|
if (!ACCEPTED_FORMATS.includes(ext)) {
|
||||||
|
return { valid: false, error: 'Invalid file type. Accepted: MP3, WAV' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > MAX_FILE_SIZE) {
|
||||||
|
return { valid: false, error: 'File too large. Maximum: 25MB' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileSelect(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
const file = target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
const validation = validateFile(file);
|
||||||
|
if (validation.valid) {
|
||||||
|
selectedFile = file;
|
||||||
|
error = null;
|
||||||
|
transcriptionResult = null;
|
||||||
|
} else {
|
||||||
|
error = validation.error || "Invalid file";
|
||||||
|
selectedFile = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(event: DragEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
isDragging = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave() {
|
||||||
|
isDragging = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(event: DragEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
isDragging = false;
|
||||||
|
|
||||||
|
const file = event.dataTransfer?.files[0];
|
||||||
|
if (file) {
|
||||||
|
const validation = validateFile(file);
|
||||||
|
if (validation.valid) {
|
||||||
|
selectedFile = file;
|
||||||
|
error = null;
|
||||||
|
transcriptionResult = null;
|
||||||
|
} else {
|
||||||
|
error = validation.error || "Invalid file";
|
||||||
|
selectedFile = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function transcribe() {
|
||||||
|
if (!selectedFile || !$selectedModelStore || isTranscribing) return;
|
||||||
|
|
||||||
|
isTranscribing = true;
|
||||||
|
error = null;
|
||||||
|
transcriptionResult = null;
|
||||||
|
abortController = new AbortController();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await transcribeAudio(
|
||||||
|
$selectedModelStore,
|
||||||
|
selectedFile,
|
||||||
|
abortController.signal
|
||||||
|
);
|
||||||
|
|
||||||
|
transcriptionResult = response.text;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.name === "AbortError") {
|
||||||
|
// User cancelled
|
||||||
|
} else {
|
||||||
|
error = err instanceof Error ? err.message : "An error occurred";
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isTranscribing = false;
|
||||||
|
abortController = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelTranscription() {
|
||||||
|
abortController?.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAll() {
|
||||||
|
selectedFile = null;
|
||||||
|
transcriptionResult = null;
|
||||||
|
error = null;
|
||||||
|
if (fileInput) {
|
||||||
|
fileInput.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyToClipboard() {
|
||||||
|
if (transcriptionResult) {
|
||||||
|
navigator.clipboard.writeText(transcriptionResult);
|
||||||
|
copied = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
copied = false;
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return bytes + ' B';
|
||||||
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||||
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col h-full">
|
||||||
|
<!-- Model selector -->
|
||||||
|
<div class="shrink-0 flex flex-wrap gap-2 mb-4">
|
||||||
|
<ModelSelector bind:value={$selectedModelStore} placeholder="Select an audio model..." disabled={isTranscribing} />
|
||||||
|
<button class="btn" onclick={clearAll} disabled={!selectedFile && !transcriptionResult && !error}>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state for no models configured -->
|
||||||
|
{#if !hasModels}
|
||||||
|
<div class="flex-1 flex items-center justify-center text-txtsecondary">
|
||||||
|
<p>No models configured. Add models to your configuration to transcribe audio.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- File upload / Result display area -->
|
||||||
|
<div class="flex-1 overflow-auto mb-4 flex items-center justify-center bg-surface border border-gray-200 dark:border-white/10 rounded">
|
||||||
|
{#if isTranscribing}
|
||||||
|
<div class="text-center text-txtsecondary">
|
||||||
|
<div class="inline-block w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin mb-2"></div>
|
||||||
|
<p>Transcribing audio...</p>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="text-center text-red-500 p-4">
|
||||||
|
<p class="font-medium">Error</p>
|
||||||
|
<p class="text-sm mt-1">{error}</p>
|
||||||
|
</div>
|
||||||
|
{:else if transcriptionResult}
|
||||||
|
<div class="w-full h-full flex flex-col p-4">
|
||||||
|
<div class="flex justify-between items-center mb-2">
|
||||||
|
<h3 class="font-medium">Transcription Result</h3>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm"
|
||||||
|
onclick={copyToClipboard}
|
||||||
|
title={copied ? 'Copied!' : 'Copy to clipboard'}
|
||||||
|
>
|
||||||
|
{#if copied}
|
||||||
|
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 overflow-auto p-3 rounded border border-gray-200 dark:border-white/10 bg-background whitespace-pre-wrap">
|
||||||
|
{transcriptionResult}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if selectedFile}
|
||||||
|
<div class="text-center text-txtsecondary p-4">
|
||||||
|
<p class="font-medium mb-2">File Selected</p>
|
||||||
|
<p class="text-sm">{selectedFile.name}</p>
|
||||||
|
<p class="text-xs mt-1">{formatFileSize(selectedFile.size)}</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
role="region"
|
||||||
|
aria-label="Audio file drop zone"
|
||||||
|
class="w-full h-full flex items-center justify-center text-center text-txtsecondary p-8 {isDragging ? 'bg-primary/10' : ''}"
|
||||||
|
ondragover={handleDragOver}
|
||||||
|
ondragleave={handleDragLeave}
|
||||||
|
ondrop={handleDrop}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p class="mb-2">Drag and drop an audio file here</p>
|
||||||
|
<p class="text-sm">or use the Browse button below</p>
|
||||||
|
<p class="text-xs mt-4">Accepted formats: MP3, WAV (max 25MB)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- File input and transcribe button -->
|
||||||
|
<div class="shrink-0 flex gap-2">
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".mp3,.wav"
|
||||||
|
class="hidden"
|
||||||
|
onchange={handleFileSelect}
|
||||||
|
bind:this={fileInput}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
onclick={() => fileInput?.click()}
|
||||||
|
disabled={isTranscribing}
|
||||||
|
>
|
||||||
|
Browse Files
|
||||||
|
</button>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
{#if isTranscribing}
|
||||||
|
<button class="btn bg-red-500 hover:bg-red-600 text-white" onclick={cancelTranscription}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="btn bg-primary text-btn-primary-text hover:opacity-90"
|
||||||
|
onclick={transcribe}
|
||||||
|
disabled={!canTranscribe}
|
||||||
|
>
|
||||||
|
Transcribe
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,424 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { models } from "../../stores/api";
|
||||||
|
import { persistentStore } from "../../stores/persistent";
|
||||||
|
import { streamChatCompletion } from "../../lib/chatApi";
|
||||||
|
import type { ChatMessage, ContentPart } from "../../lib/types";
|
||||||
|
import ChatMessageComponent from "./ChatMessage.svelte";
|
||||||
|
import ModelSelector from "./ModelSelector.svelte";
|
||||||
|
import ExpandableTextarea from "./ExpandableTextarea.svelte";
|
||||||
|
|
||||||
|
const selectedModelStore = persistentStore<string>("playground-selected-model", "");
|
||||||
|
const systemPromptStore = persistentStore<string>("playground-system-prompt", "");
|
||||||
|
const temperatureStore = persistentStore<number>("playground-temperature", 0.7);
|
||||||
|
|
||||||
|
let messages = $state<ChatMessage[]>([]);
|
||||||
|
let userInput = $state("");
|
||||||
|
let isStreaming = $state(false);
|
||||||
|
let isReasoning = $state(false);
|
||||||
|
let reasoningStartTime = $state<number>(0);
|
||||||
|
let abortController = $state<AbortController | null>(null);
|
||||||
|
let messagesContainer: HTMLDivElement | undefined = $state();
|
||||||
|
let showSettings = $state(false);
|
||||||
|
let attachedImages = $state<string[]>([]);
|
||||||
|
let fileInput = $state<HTMLInputElement | null>(null);
|
||||||
|
let imageError = $state<string | null>(null);
|
||||||
|
|
||||||
|
let hasModels = $derived($models.some((m) => !m.unlisted));
|
||||||
|
|
||||||
|
// Auto-scroll when messages change
|
||||||
|
$effect(() => {
|
||||||
|
if (messages.length > 0 && messagesContainer) {
|
||||||
|
messagesContainer.scrollTo({
|
||||||
|
top: messagesContainer.scrollHeight,
|
||||||
|
behavior: "smooth",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function sendMessage() {
|
||||||
|
const trimmedInput = userInput.trim();
|
||||||
|
if ((!trimmedInput && attachedImages.length === 0) || !$selectedModelStore || isStreaming) return;
|
||||||
|
|
||||||
|
// Build message content (multimodal if images attached)
|
||||||
|
let content: string | ContentPart[];
|
||||||
|
if (attachedImages.length > 0) {
|
||||||
|
const parts: ContentPart[] = [];
|
||||||
|
if (trimmedInput) {
|
||||||
|
parts.push({ type: "text", text: trimmedInput });
|
||||||
|
}
|
||||||
|
for (const url of attachedImages) {
|
||||||
|
parts.push({ type: "image_url", image_url: { url } });
|
||||||
|
}
|
||||||
|
content = parts;
|
||||||
|
} else {
|
||||||
|
content = trimmedInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add user message
|
||||||
|
messages = [...messages, { role: "user", content }];
|
||||||
|
userInput = "";
|
||||||
|
attachedImages = [];
|
||||||
|
imageError = null;
|
||||||
|
|
||||||
|
// Generate response from the new user message
|
||||||
|
await regenerateFromIndex(messages.length - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelStreaming() {
|
||||||
|
abortController?.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function newChat() {
|
||||||
|
if (isStreaming) {
|
||||||
|
cancelStreaming();
|
||||||
|
}
|
||||||
|
messages = [];
|
||||||
|
isReasoning = false;
|
||||||
|
reasoningStartTime = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function regenerateFromIndex(idx: number) {
|
||||||
|
// Remove all messages after the edited user message
|
||||||
|
messages = messages.slice(0, idx + 1);
|
||||||
|
|
||||||
|
// Add empty assistant message for the new response
|
||||||
|
messages = [...messages, { role: "assistant", content: "" }];
|
||||||
|
|
||||||
|
isStreaming = true;
|
||||||
|
isReasoning = false;
|
||||||
|
reasoningStartTime = 0;
|
||||||
|
abortController = new AbortController();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Build messages array with optional system prompt
|
||||||
|
const apiMessages: ChatMessage[] = [];
|
||||||
|
if ($systemPromptStore.trim()) {
|
||||||
|
apiMessages.push({ role: "system", content: $systemPromptStore.trim() });
|
||||||
|
}
|
||||||
|
apiMessages.push(...messages.slice(0, -1)); // Add all messages except the empty assistant one
|
||||||
|
|
||||||
|
const stream = streamChatCompletion(
|
||||||
|
$selectedModelStore,
|
||||||
|
apiMessages,
|
||||||
|
abortController.signal,
|
||||||
|
{ temperature: $temperatureStore }
|
||||||
|
);
|
||||||
|
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
if (chunk.done) break;
|
||||||
|
|
||||||
|
// Handle reasoning content
|
||||||
|
if (chunk.reasoning_content) {
|
||||||
|
// Start timing on first reasoning content
|
||||||
|
if (!isReasoning) {
|
||||||
|
isReasoning = true;
|
||||||
|
reasoningStartTime = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the last message with reasoning content
|
||||||
|
messages = messages.map((msg, i) =>
|
||||||
|
i === messages.length - 1
|
||||||
|
? { ...msg, reasoning_content: (msg.reasoning_content || "") + chunk.reasoning_content }
|
||||||
|
: msg
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle regular content - end reasoning phase when we get content
|
||||||
|
if (chunk.content) {
|
||||||
|
if (isReasoning) {
|
||||||
|
// Calculate reasoning time
|
||||||
|
const reasoningTimeMs = Date.now() - reasoningStartTime;
|
||||||
|
isReasoning = false;
|
||||||
|
|
||||||
|
// Update message with reasoning time
|
||||||
|
messages = messages.map((msg, i) =>
|
||||||
|
i === messages.length - 1
|
||||||
|
? { ...msg, reasoningTimeMs }
|
||||||
|
: msg
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the last message (assistant) with new content
|
||||||
|
messages = messages.map((msg, i) =>
|
||||||
|
i === messages.length - 1
|
||||||
|
? { ...msg, content: msg.content + chunk.content }
|
||||||
|
: msg
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.name === "AbortError") {
|
||||||
|
// User cancelled, keep partial response
|
||||||
|
// If we were still reasoning, record the time
|
||||||
|
if (isReasoning && reasoningStartTime > 0) {
|
||||||
|
const reasoningTimeMs = Date.now() - reasoningStartTime;
|
||||||
|
messages = messages.map((msg, i) =>
|
||||||
|
i === messages.length - 1
|
||||||
|
? { ...msg, reasoningTimeMs }
|
||||||
|
: msg
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Show error in the assistant message
|
||||||
|
const errorMessage = error instanceof Error ? error.message : "An error occurred";
|
||||||
|
messages = messages.map((msg, i) =>
|
||||||
|
i === messages.length - 1
|
||||||
|
? { ...msg, content: msg.content + `\n\n**Error:** ${errorMessage}` }
|
||||||
|
: msg
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isStreaming = false;
|
||||||
|
isReasoning = false;
|
||||||
|
abortController = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editMessage(idx: number, newContent: string) {
|
||||||
|
if (isStreaming || !$selectedModelStore) return;
|
||||||
|
|
||||||
|
// Update the user message at the specified index
|
||||||
|
messages = messages.map((msg, i) =>
|
||||||
|
i === idx ? { ...msg, content: newContent } : msg
|
||||||
|
);
|
||||||
|
|
||||||
|
// Trigger a new chat request with the updated messages
|
||||||
|
await regenerateFromIndex(idx);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Enter" && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
sendMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACCEPTED_IMAGE_FORMATS = ["image/jpeg", "image/png", "image/gif", "image/webp"];
|
||||||
|
const MAX_IMAGE_SIZE = 20 * 1024 * 1024; // 20MB
|
||||||
|
const MAX_IMAGES_PER_MESSAGE = 5;
|
||||||
|
|
||||||
|
function validateImageFile(file: File): string | null {
|
||||||
|
if (!ACCEPTED_IMAGE_FORMATS.includes(file.type)) {
|
||||||
|
return `Invalid file type: ${file.type}. Accepted formats: JPG, PNG, GIF, WEBP`;
|
||||||
|
}
|
||||||
|
if (file.size > MAX_IMAGE_SIZE) {
|
||||||
|
return `File too large: ${(file.size / 1024 / 1024).toFixed(1)}MB. Maximum size: 20MB`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileToDataUrl(file: File): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(reader.result as string);
|
||||||
|
reader.onerror = () => reject(new Error("Failed to read file"));
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function processImageFiles(files: File[]): Promise<void> {
|
||||||
|
imageError = null;
|
||||||
|
|
||||||
|
if (attachedImages.length + files.length > MAX_IMAGES_PER_MESSAGE) {
|
||||||
|
imageError = `Maximum ${MAX_IMAGES_PER_MESSAGE} images per message`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const error = validateImageFile(file);
|
||||||
|
if (error) {
|
||||||
|
imageError = error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dataUrls = await Promise.all(files.map(fileToDataUrl));
|
||||||
|
attachedImages = [...attachedImages, ...dataUrls];
|
||||||
|
} catch (error) {
|
||||||
|
imageError = error instanceof Error ? error.message : "Failed to process images";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleImageSelect(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
if (input.files && input.files.length > 0) {
|
||||||
|
processImageFiles(Array.from(input.files));
|
||||||
|
}
|
||||||
|
// Reset the input so the same file can be selected again
|
||||||
|
input.value = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeImage(idx: number) {
|
||||||
|
attachedImages = attachedImages.filter((_, i) => i !== idx);
|
||||||
|
imageError = null;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col h-full">
|
||||||
|
<!-- Model selector and controls -->
|
||||||
|
<div class="shrink-0 flex flex-wrap gap-2 mb-4">
|
||||||
|
<ModelSelector bind:value={$selectedModelStore} placeholder="Select a model..." disabled={isStreaming} />
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
onclick={() => (showSettings = !showSettings)}
|
||||||
|
title="Settings"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
||||||
|
<path fill-rule="evenodd" d="M8.34 1.804A1 1 0 0 1 9.32 1h1.36a1 1 0 0 1 .98.804l.295 1.473c.497.144.971.342 1.416.587l1.25-.834a1 1 0 0 1 1.262.125l.962.962a1 1 0 0 1 .125 1.262l-.834 1.25c.245.445.443.919.587 1.416l1.473.295a1 1 0 0 1 .804.98v1.36a1 1 0 0 1-.804.98l-1.473.295a6.95 6.95 0 0 1-.587 1.416l.834 1.25a1 1 0 0 1-.125 1.262l-.962.962a1 1 0 0 1-1.262.125l-1.25-.834a6.953 6.953 0 0 1-1.416.587l-.295 1.473a1 1 0 0 1-.98.804H9.32a1 1 0 0 1-.98-.804l-.295-1.473a6.957 6.957 0 0 1-1.416-.587l-1.25.834a1 1 0 0 1-1.262-.125l-.962-.962a1 1 0 0 1-.125-1.262l.834-1.25a6.957 6.957 0 0 1-.587-1.416l-1.473-.295A1 1 0 0 1 1 10.68V9.32a1 1 0 0 1 .804-.98l1.473-.295c.144-.497.342-.971.587-1.416l-.834-1.25a1 1 0 0 1 .125-1.262l.962-.962A1 1 0 0 1 5.38 3.03l1.25.834a6.957 6.957 0 0 1 1.416-.587l.294-1.473ZM13 10a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="btn" onclick={newChat} disabled={messages.length === 0 && !isStreaming}>
|
||||||
|
New Chat
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings panel -->
|
||||||
|
{#if showSettings}
|
||||||
|
<div class="shrink-0 mb-4 p-4 bg-surface border border-gray-200 dark:border-white/10 rounded">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium mb-1" for="system-prompt">System Prompt</label>
|
||||||
|
<textarea
|
||||||
|
id="system-prompt"
|
||||||
|
class="w-full px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-card focus:outline-none focus:ring-2 focus:ring-primary resize-none"
|
||||||
|
placeholder="You are a helpful assistant..."
|
||||||
|
rows="3"
|
||||||
|
bind:value={$systemPromptStore}
|
||||||
|
disabled={isStreaming}
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1" for="temperature">
|
||||||
|
Temperature: {$temperatureStore.toFixed(2)}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="temperature"
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="2"
|
||||||
|
step="0.05"
|
||||||
|
class="w-full"
|
||||||
|
bind:value={$temperatureStore}
|
||||||
|
disabled={isStreaming}
|
||||||
|
/>
|
||||||
|
<div class="flex justify-between text-xs text-txtsecondary mt-1">
|
||||||
|
<span>Precise (0)</span>
|
||||||
|
<span>Creative (2)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Empty state for no models configured -->
|
||||||
|
{#if !hasModels}
|
||||||
|
<div class="flex-1 flex items-center justify-center text-txtsecondary">
|
||||||
|
<p>No models configured. Add models to your configuration to start chatting.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Messages area -->
|
||||||
|
<div
|
||||||
|
class="flex-1 overflow-y-auto mb-4 px-2"
|
||||||
|
bind:this={messagesContainer}
|
||||||
|
>
|
||||||
|
{#if messages.length === 0}
|
||||||
|
<div class="h-full flex items-center justify-center text-txtsecondary">
|
||||||
|
<p>Start a conversation by typing a message below.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each messages as message, idx (idx)}
|
||||||
|
<ChatMessageComponent
|
||||||
|
role={message.role}
|
||||||
|
content={message.content}
|
||||||
|
reasoning_content={message.reasoning_content}
|
||||||
|
reasoningTimeMs={message.reasoningTimeMs}
|
||||||
|
isStreaming={isStreaming && idx === messages.length - 1 && message.role === "assistant"}
|
||||||
|
isReasoning={isReasoning && idx === messages.length - 1 && message.role === "assistant"}
|
||||||
|
onEdit={message.role === "user" ? (newContent) => editMessage(idx, newContent) : undefined}
|
||||||
|
onRegenerate={message.role === "assistant" && idx > 0 && messages[idx - 1].role === "user"
|
||||||
|
? () => regenerateFromIndex(idx - 1)
|
||||||
|
: undefined}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input area -->
|
||||||
|
<div class="shrink-0">
|
||||||
|
<!-- Image preview strip -->
|
||||||
|
{#if attachedImages.length > 0}
|
||||||
|
<div class="mb-2 flex flex-wrap gap-2">
|
||||||
|
{#each attachedImages as imageUrl, idx (idx)}
|
||||||
|
<div class="relative group">
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt="Attached image {idx + 1}"
|
||||||
|
class="w-20 h-20 object-cover rounded border border-gray-200 dark:border-white/10"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
onclick={() => removeImage(idx)}
|
||||||
|
title="Remove image"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Error message -->
|
||||||
|
{#if imageError}
|
||||||
|
<div class="mb-2 p-2 bg-red-100 dark:bg-red-900/20 text-red-700 dark:text-red-400 rounded text-sm">
|
||||||
|
{imageError}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<!-- Hidden file input -->
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept=".jpg,.jpeg,.png,.gif,.webp"
|
||||||
|
multiple
|
||||||
|
class="hidden"
|
||||||
|
bind:this={fileInput}
|
||||||
|
onchange={handleImageSelect}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ExpandableTextarea
|
||||||
|
bind:value={userInput}
|
||||||
|
placeholder="Type a message..."
|
||||||
|
rows={3}
|
||||||
|
onkeydown={handleKeyDown}
|
||||||
|
disabled={isStreaming || !$selectedModelStore}
|
||||||
|
/>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
{#if isStreaming}
|
||||||
|
<button class="btn bg-red-500 hover:bg-red-600 text-white" onclick={cancelStreaming}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
onclick={() => fileInput?.click()}
|
||||||
|
disabled={isStreaming || !$selectedModelStore}
|
||||||
|
title="Attach image"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
||||||
|
<path fill-rule="evenodd" d="M1 5.25A2.25 2.25 0 0 1 3.25 3h13.5A2.25 2.25 0 0 1 19 5.25v9.5A2.25 2.25 0 0 1 16.75 17H3.25A2.25 2.25 0 0 1 1 14.75v-9.5Zm1.5 5.81v3.69c0 .414.336.75.75.75h13.5a.75.75 0 0 0 .75-.75v-2.69l-2.22-2.219a.75.75 0 0 0-1.06 0l-1.91 1.909.47.47a.75.75 0 1 1-1.06 1.06L6.53 8.091a.75.75 0 0 0-1.06 0l-2.97 2.97ZM12 7a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn bg-primary text-btn-primary-text hover:opacity-90"
|
||||||
|
onclick={sendMessage}
|
||||||
|
disabled={(!userInput.trim() && attachedImages.length === 0) || !$selectedModelStore}
|
||||||
|
>
|
||||||
|
Send
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { renderMarkdown, escapeHtml } from "../../lib/markdown";
|
||||||
|
import { Copy, Check, Pencil, X, Save, RefreshCw, ChevronDown, ChevronRight, Brain, Code } from "lucide-svelte";
|
||||||
|
import { getTextContent, getImageUrls } from "../../lib/types";
|
||||||
|
import type { ContentPart } from "../../lib/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
role: "user" | "assistant" | "system";
|
||||||
|
content: string | ContentPart[];
|
||||||
|
reasoning_content?: string;
|
||||||
|
reasoningTimeMs?: number;
|
||||||
|
isStreaming?: boolean;
|
||||||
|
isReasoning?: boolean;
|
||||||
|
onEdit?: (newContent: string) => void;
|
||||||
|
onRegenerate?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { role, content, reasoning_content = "", reasoningTimeMs = 0, isStreaming = false, isReasoning = false, onEdit, onRegenerate }: Props = $props();
|
||||||
|
|
||||||
|
let textContent = $derived(getTextContent(content));
|
||||||
|
let imageUrls = $derived(getImageUrls(content));
|
||||||
|
let hasImages = $derived(imageUrls.length > 0);
|
||||||
|
let canEdit = $derived(onEdit !== undefined && !hasImages);
|
||||||
|
|
||||||
|
let renderedContent = $derived(
|
||||||
|
role === "assistant" && !isStreaming
|
||||||
|
? renderMarkdown(textContent)
|
||||||
|
: escapeHtml(textContent).replace(/\n/g, '<br>')
|
||||||
|
);
|
||||||
|
let copied = $state(false);
|
||||||
|
let showRaw = $state(false);
|
||||||
|
let isEditing = $state(false);
|
||||||
|
let editContent = $state("");
|
||||||
|
let showReasoning = $state(false);
|
||||||
|
let modalImageUrl = $state<string | null>(null);
|
||||||
|
|
||||||
|
function formatDuration(ms: number): string {
|
||||||
|
if (ms < 1000) {
|
||||||
|
return `${ms.toFixed(0)}ms`;
|
||||||
|
}
|
||||||
|
return `${(ms / 1000).toFixed(1)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyToClipboard() {
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(textContent);
|
||||||
|
} else {
|
||||||
|
// Fallback for non-secure contexts (HTTP)
|
||||||
|
const textarea = document.createElement("textarea");
|
||||||
|
textarea.value = textContent;
|
||||||
|
textarea.style.position = "fixed";
|
||||||
|
textarea.style.left = "-9999px";
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
document.execCommand("copy");
|
||||||
|
document.body.removeChild(textarea);
|
||||||
|
}
|
||||||
|
copied = true;
|
||||||
|
setTimeout(() => (copied = false), 2000);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to copy:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit() {
|
||||||
|
editContent = textContent;
|
||||||
|
isEditing = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
isEditing = false;
|
||||||
|
editContent = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveEdit() {
|
||||||
|
if (onEdit && editContent.trim() !== textContent) {
|
||||||
|
onEdit(editContent.trim());
|
||||||
|
}
|
||||||
|
isEditing = false;
|
||||||
|
editContent = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function openModal(imageUrl: string) {
|
||||||
|
modalImageUrl = imageUrl;
|
||||||
|
document.body.style.overflow = "hidden";
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal(event?: MouseEvent) {
|
||||||
|
// Only close if clicking the background, not the image
|
||||||
|
if (event && event.target !== event.currentTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
modalImageUrl = null;
|
||||||
|
document.body.style.overflow = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleModalKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Enter" && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
saveEdit();
|
||||||
|
} else if (event.key === "Escape") {
|
||||||
|
cancelEdit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex {role === 'user' ? 'justify-end' : 'justify-start'} mb-4">
|
||||||
|
<div
|
||||||
|
class="relative group max-w-[85%] rounded-lg px-4 py-2 {role === 'user'
|
||||||
|
? 'bg-primary text-btn-primary-text'
|
||||||
|
: 'bg-surface border border-gray-200 dark:border-white/10'}"
|
||||||
|
>
|
||||||
|
{#if role === "assistant"}
|
||||||
|
{#if reasoning_content || isReasoning}
|
||||||
|
<div class="mb-3 border border-gray-200 dark:border-white/10 rounded overflow-hidden">
|
||||||
|
<button
|
||||||
|
class="w-full flex items-center gap-2 px-3 py-2 bg-gray-50 dark:bg-white/5 hover:bg-gray-100 dark:hover:bg-white/10 transition-colors text-sm"
|
||||||
|
onclick={() => showReasoning = !showReasoning}
|
||||||
|
>
|
||||||
|
{#if showReasoning}
|
||||||
|
<ChevronDown class="w-4 h-4" />
|
||||||
|
{:else}
|
||||||
|
<ChevronRight class="w-4 h-4" />
|
||||||
|
{/if}
|
||||||
|
<Brain class="w-4 h-4" />
|
||||||
|
<span class="font-medium">Reasoning</span>
|
||||||
|
<span class="text-txtsecondary ml-2">
|
||||||
|
({reasoning_content.length} chars{#if !isReasoning && reasoningTimeMs > 0}, {formatDuration(reasoningTimeMs)}{/if})
|
||||||
|
</span>
|
||||||
|
{#if isReasoning}
|
||||||
|
<span class="ml-auto flex items-center gap-1 text-txtsecondary">
|
||||||
|
<span class="w-1.5 h-1.5 bg-primary rounded-full animate-pulse"></span>
|
||||||
|
reasoning...
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{#if showReasoning}
|
||||||
|
<div class="px-3 py-2 bg-gray-50/50 dark:bg-white/[0.02] text-sm text-txtsecondary whitespace-pre-wrap font-mono">
|
||||||
|
{reasoning_content}{#if isReasoning}<span class="inline-block w-1.5 h-4 bg-current animate-pulse ml-0.5"></span>{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if hasImages}
|
||||||
|
<div class="mb-3 flex flex-wrap gap-2">
|
||||||
|
{#each imageUrls as imageUrl, idx (idx)}
|
||||||
|
<button
|
||||||
|
onclick={() => openModal(imageUrl)}
|
||||||
|
class="cursor-pointer rounded border border-gray-200 dark:border-white/10 hover:opacity-80 transition-opacity"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt="Image {idx + 1}"
|
||||||
|
class="max-h-64 rounded"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if showRaw}
|
||||||
|
<div class="whitespace-pre-wrap font-mono text-sm">{textContent}</div>
|
||||||
|
{:else}
|
||||||
|
<div class="prose prose-sm dark:prose-invert max-w-none">
|
||||||
|
{@html renderedContent}
|
||||||
|
{#if isStreaming && !isReasoning}
|
||||||
|
<span class="inline-block w-2 h-4 bg-current animate-pulse ml-0.5"></span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if !isStreaming}
|
||||||
|
<div class="flex gap-1 mt-2 pt-1 border-t border-gray-200 dark:border-white/10">
|
||||||
|
{#if onRegenerate}
|
||||||
|
<button
|
||||||
|
class="p-1 rounded hover:bg-black/10 dark:hover:bg-white/10 text-txtsecondary"
|
||||||
|
onclick={onRegenerate}
|
||||||
|
title="Regenerate response"
|
||||||
|
>
|
||||||
|
<RefreshCw class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="p-1 rounded hover:bg-black/10 dark:hover:bg-white/10 text-txtsecondary"
|
||||||
|
onclick={copyToClipboard}
|
||||||
|
title={copied ? "Copied!" : "Copy to clipboard"}
|
||||||
|
>
|
||||||
|
{#if copied}
|
||||||
|
<Check class="w-4 h-4 text-green-500" />
|
||||||
|
{:else}
|
||||||
|
<Copy class="w-4 h-4" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="p-1 rounded hover:bg-black/10 dark:hover:bg-white/10 {showRaw ? 'text-primary' : 'text-txtsecondary'}"
|
||||||
|
onclick={() => showRaw = !showRaw}
|
||||||
|
title={showRaw ? "Show rendered" : "Show raw"}
|
||||||
|
>
|
||||||
|
<Code class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
{#if isEditing}
|
||||||
|
<div class="flex flex-col gap-2 min-w-[300px]">
|
||||||
|
<textarea
|
||||||
|
class="w-full px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-surface text-txtmain focus:outline-none focus:ring-2 focus:ring-primary resize-none"
|
||||||
|
rows="3"
|
||||||
|
bind:value={editContent}
|
||||||
|
onkeydown={handleKeyDown}
|
||||||
|
></textarea>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
class="p-1.5 rounded hover:bg-white/20"
|
||||||
|
onclick={cancelEdit}
|
||||||
|
title="Cancel"
|
||||||
|
>
|
||||||
|
<X class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="p-1.5 rounded hover:bg-white/20"
|
||||||
|
onclick={saveEdit}
|
||||||
|
title="Save"
|
||||||
|
>
|
||||||
|
<Save class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#if hasImages}
|
||||||
|
<div class="mb-2 flex flex-wrap gap-2">
|
||||||
|
{#each imageUrls as imageUrl, idx (idx)}
|
||||||
|
<button
|
||||||
|
onclick={() => openModal(imageUrl)}
|
||||||
|
class="cursor-pointer rounded border border-white/20 hover:opacity-80 transition-opacity"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt="Image {idx + 1}"
|
||||||
|
class="max-w-[200px] rounded"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="whitespace-pre-wrap pr-8">{textContent}</div>
|
||||||
|
{#if canEdit}
|
||||||
|
<button
|
||||||
|
class="absolute top-2 right-2 p-1.5 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity bg-white/20 hover:bg-white/30 shadow-sm"
|
||||||
|
onclick={startEdit}
|
||||||
|
title="Edit message"
|
||||||
|
>
|
||||||
|
<Pencil class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Full-size image modal -->
|
||||||
|
{#if modalImageUrl}
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
|
||||||
|
onclick={(e) => closeModal(e)}
|
||||||
|
onkeydown={handleModalKeyDown}
|
||||||
|
role="button"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="absolute top-4 right-4 p-2 rounded-lg bg-white/10 hover:bg-white/20 text-white transition-colors"
|
||||||
|
onclick={() => closeModal()}
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
<X class="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
<img
|
||||||
|
src={modalImageUrl}
|
||||||
|
alt=""
|
||||||
|
class="max-w-full max-h-full rounded pointer-events-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.prose :global(pre) {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border, rgba(128, 128, 128, 0.2));
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(code) {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
|
font-size: 0.875em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(pre code) {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(code:not(pre code)) {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
padding: 0.125rem 0.25rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
border: 1px solid var(--color-border, rgba(128, 128, 128, 0.2));
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(p) {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(p:first-child) {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(p:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(ul),
|
||||||
|
.prose :global(ol) {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
padding-left: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(li) {
|
||||||
|
margin: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(h1),
|
||||||
|
.prose :global(h2),
|
||||||
|
.prose :global(h3),
|
||||||
|
.prose :global(h4) {
|
||||||
|
margin: 1rem 0 0.5rem 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(h1:first-child),
|
||||||
|
.prose :global(h2:first-child),
|
||||||
|
.prose :global(h3:first-child),
|
||||||
|
.prose :global(h4:first-child) {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(blockquote) {
|
||||||
|
border-left: 3px solid var(--color-primary);
|
||||||
|
padding-left: 1rem;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(a) {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(table) {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(th),
|
||||||
|
.prose :global(td) {
|
||||||
|
border: 1px solid var(--color-border, rgba(128, 128, 128, 0.2));
|
||||||
|
padding: 0.5rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :global(th) {
|
||||||
|
background-color: var(--color-surface);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Highlight.js theme overrides for dark mode */
|
||||||
|
:global(.dark) .prose :global(.hljs) {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Maximize2, X } from "lucide-svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string;
|
||||||
|
placeholder?: string;
|
||||||
|
rows?: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
onkeydown?: (event: KeyboardEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
value = $bindable(),
|
||||||
|
placeholder = "",
|
||||||
|
rows = 3,
|
||||||
|
disabled = false,
|
||||||
|
onkeydown,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let isExpanded = $state(false);
|
||||||
|
let expandedValue = $state("");
|
||||||
|
let expandedTextarea: HTMLTextAreaElement | undefined = $state();
|
||||||
|
|
||||||
|
function openExpanded() {
|
||||||
|
expandedValue = value;
|
||||||
|
isExpanded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeExpanded() {
|
||||||
|
isExpanded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveExpanded() {
|
||||||
|
value = expandedValue;
|
||||||
|
isExpanded = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
closeExpanded();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus the textarea when expanded view opens
|
||||||
|
$effect(() => {
|
||||||
|
if (isExpanded && expandedTextarea) {
|
||||||
|
expandedTextarea.focus();
|
||||||
|
expandedTextarea.setSelectionRange(expandedValue.length, expandedValue.length);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex-1 relative group flex items-stretch min-h-0">
|
||||||
|
<textarea
|
||||||
|
class="w-full px-3 py-2 pr-10 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary resize-none"
|
||||||
|
{placeholder}
|
||||||
|
{rows}
|
||||||
|
bind:value
|
||||||
|
{onkeydown}
|
||||||
|
{disabled}
|
||||||
|
></textarea>
|
||||||
|
<button
|
||||||
|
class="absolute top-2 right-2 p-1.5 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity bg-surface/90 hover:bg-surface border border-gray-200 dark:border-white/10 shadow-sm"
|
||||||
|
onclick={openExpanded}
|
||||||
|
title="Expand to edit"
|
||||||
|
type="button"
|
||||||
|
{disabled}
|
||||||
|
>
|
||||||
|
<Maximize2 class="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if isExpanded}
|
||||||
|
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||||
|
<div class="w-full max-w-4xl h-[80vh] flex flex-col bg-surface rounded-lg shadow-xl border border-gray-200 dark:border-white/10">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex justify-between items-center p-4 border-b border-gray-200 dark:border-white/10">
|
||||||
|
<h3 class="font-medium">Edit Text</h3>
|
||||||
|
<button
|
||||||
|
class="p-1.5 rounded-lg hover:bg-gray-100 dark:hover:bg-white/10"
|
||||||
|
onclick={closeExpanded}
|
||||||
|
title="Close"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<X class="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Textarea -->
|
||||||
|
<div class="flex-1 p-4">
|
||||||
|
<textarea
|
||||||
|
bind:this={expandedTextarea}
|
||||||
|
class="w-full h-full px-4 py-3 rounded border border-gray-200 dark:border-white/10 bg-card focus:outline-none focus:ring-2 focus:ring-primary resize-none"
|
||||||
|
placeholder={placeholder}
|
||||||
|
bind:value={expandedValue}
|
||||||
|
onkeydown={handleKeyDown}
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div class="flex justify-end gap-2 p-4 border-t border-gray-200 dark:border-white/10">
|
||||||
|
<button
|
||||||
|
class="btn"
|
||||||
|
onclick={closeExpanded}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn bg-primary text-btn-primary-text hover:opacity-90"
|
||||||
|
onclick={saveExpanded}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { models } from "../../stores/api";
|
||||||
|
import { persistentStore } from "../../stores/persistent";
|
||||||
|
import { generateImage } from "../../lib/imageApi";
|
||||||
|
import ModelSelector from "./ModelSelector.svelte";
|
||||||
|
import ExpandableTextarea from "./ExpandableTextarea.svelte";
|
||||||
|
|
||||||
|
const selectedModelStore = persistentStore<string>("playground-image-model", "");
|
||||||
|
const selectedSizeStore = persistentStore<string>("playground-image-size", "1024x1024");
|
||||||
|
|
||||||
|
let prompt = $state("");
|
||||||
|
let isGenerating = $state(false);
|
||||||
|
let generatedImage = $state<string | null>(null);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let abortController = $state<AbortController | null>(null);
|
||||||
|
let showFullscreen = $state(false);
|
||||||
|
|
||||||
|
let hasModels = $derived($models.some((m) => !m.unlisted));
|
||||||
|
|
||||||
|
async function generate() {
|
||||||
|
const trimmedPrompt = prompt.trim();
|
||||||
|
if (!trimmedPrompt || !$selectedModelStore || isGenerating) return;
|
||||||
|
|
||||||
|
isGenerating = true;
|
||||||
|
error = null;
|
||||||
|
abortController = new AbortController();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await generateImage(
|
||||||
|
$selectedModelStore,
|
||||||
|
trimmedPrompt,
|
||||||
|
$selectedSizeStore,
|
||||||
|
abortController.signal
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.data && response.data.length > 0) {
|
||||||
|
const imageData = response.data[0];
|
||||||
|
if (imageData.b64_json) {
|
||||||
|
generatedImage = `data:image/png;base64,${imageData.b64_json}`;
|
||||||
|
} else if (imageData.url) {
|
||||||
|
generatedImage = imageData.url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.name === "AbortError") {
|
||||||
|
// User cancelled
|
||||||
|
} else {
|
||||||
|
error = err instanceof Error ? err.message : "An error occurred";
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isGenerating = false;
|
||||||
|
abortController = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelGeneration() {
|
||||||
|
abortController?.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearImage() {
|
||||||
|
generatedImage = null;
|
||||||
|
error = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadImage() {
|
||||||
|
if (!generatedImage) return;
|
||||||
|
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = generatedImage;
|
||||||
|
link.download = `generated-image-${Date.now()}.png`;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFullscreen() {
|
||||||
|
showFullscreen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeFullscreen(event?: MouseEvent) {
|
||||||
|
// Only close if clicking the background, not the image
|
||||||
|
if (event && event.target !== event.currentTarget) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showFullscreen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Enter" && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
generate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col h-full">
|
||||||
|
<!-- Model selector -->
|
||||||
|
<div class="shrink-0 flex flex-wrap gap-2 mb-4">
|
||||||
|
<ModelSelector bind:value={$selectedModelStore} placeholder="Select an image model..." disabled={isGenerating} />
|
||||||
|
<select
|
||||||
|
class="px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
bind:value={$selectedSizeStore}
|
||||||
|
disabled={isGenerating}
|
||||||
|
>
|
||||||
|
<optgroup label="Square">
|
||||||
|
<option value="512x512">512x512</option>
|
||||||
|
<option value="1024x1024">1024x1024</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Landscape">
|
||||||
|
<option value="1024x768">1024x768 (4:3)</option>
|
||||||
|
<option value="1280x720">1280x720 (16:9)</option>
|
||||||
|
<option value="1792x1024">1792x1024 (SDXL)</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="Portrait">
|
||||||
|
<option value="768x1024">768x1024 (3:4)</option>
|
||||||
|
<option value="720x1280">720x1280 (9:16)</option>
|
||||||
|
<option value="1024x1792">1024x1792 (SDXL)</option>
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
<button class="btn" onclick={clearImage} disabled={!generatedImage && !error}>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state for no models configured -->
|
||||||
|
{#if !hasModels}
|
||||||
|
<div class="flex-1 flex items-center justify-center text-txtsecondary">
|
||||||
|
<p>No models configured. Add models to your configuration to generate images.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Image display area -->
|
||||||
|
<div class="flex-1 overflow-auto mb-4 flex items-center justify-center bg-surface border border-gray-200 dark:border-white/10 rounded">
|
||||||
|
{#if isGenerating}
|
||||||
|
<div class="text-center text-txtsecondary">
|
||||||
|
<div class="inline-block w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin mb-2"></div>
|
||||||
|
<p>Generating image...</p>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="text-center text-red-500 p-4">
|
||||||
|
<p class="font-medium">Error</p>
|
||||||
|
<p class="text-sm mt-1">{error}</p>
|
||||||
|
</div>
|
||||||
|
{:else if generatedImage}
|
||||||
|
<div class="relative max-w-full max-h-full flex items-center justify-center">
|
||||||
|
<button
|
||||||
|
class="p-0 border-0 bg-transparent cursor-pointer"
|
||||||
|
onclick={openFullscreen}
|
||||||
|
aria-label="View fullscreen"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={generatedImage}
|
||||||
|
alt="AI generated content"
|
||||||
|
class="max-w-full max-h-full object-contain hover:opacity-90 transition-opacity"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="absolute bottom-2 right-2 p-2 bg-black/60 hover:bg-black/80 text-white rounded-full transition-colors"
|
||||||
|
onclick={(e) => { e.stopPropagation(); downloadImage(); }}
|
||||||
|
aria-label="Download image"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="text-center text-txtsecondary">
|
||||||
|
<p>Enter a prompt below to generate an image</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Prompt input area -->
|
||||||
|
<div class="shrink-0 flex flex-col md:flex-row gap-2">
|
||||||
|
<ExpandableTextarea
|
||||||
|
bind:value={prompt}
|
||||||
|
placeholder="Describe the image you want to generate..."
|
||||||
|
rows={3}
|
||||||
|
onkeydown={handleKeyDown}
|
||||||
|
disabled={isGenerating || !$selectedModelStore}
|
||||||
|
/>
|
||||||
|
<div class="flex flex-row md:flex-col gap-2">
|
||||||
|
{#if isGenerating}
|
||||||
|
<button class="btn bg-red-500 hover:bg-red-600 text-white flex-1 md:flex-none" onclick={cancelGeneration}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="btn bg-primary text-btn-primary-text hover:opacity-90 flex-1 md:flex-none"
|
||||||
|
onclick={generate}
|
||||||
|
disabled={!prompt.trim() || !$selectedModelStore}
|
||||||
|
>
|
||||||
|
Generate
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fullscreen dialog -->
|
||||||
|
{#if showFullscreen && generatedImage}
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 bg-black/90 z-50 flex items-center justify-center p-4"
|
||||||
|
onclick={(e) => closeFullscreen(e)}
|
||||||
|
onkeydown={(e) => e.key === 'Escape' && closeFullscreen()}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
tabindex="-1"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="absolute top-4 right-4 text-white hover:text-gray-300 text-2xl w-10 h-10 flex items-center justify-center rounded-full hover:bg-white/10 transition-colors"
|
||||||
|
onclick={() => closeFullscreen()}
|
||||||
|
aria-label="Close fullscreen"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<img
|
||||||
|
src={generatedImage}
|
||||||
|
alt="AI generated content"
|
||||||
|
class="max-w-full max-h-full object-contain pointer-events-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { models } from "../../stores/api";
|
||||||
|
import { groupModels } from "../../lib/modelUtils";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { value = $bindable(), placeholder = "Select a model...", disabled = false }: Props = $props();
|
||||||
|
|
||||||
|
let grouped = $derived(groupModels($models));
|
||||||
|
let hasModels = $derived(grouped.local.length > 0 || Object.keys(grouped.peersByProvider).length > 0);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if hasModels}
|
||||||
|
<select
|
||||||
|
class="min-w-0 flex-1 basis-48 px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
bind:value
|
||||||
|
{disabled}
|
||||||
|
>
|
||||||
|
<option value="">{placeholder}</option>
|
||||||
|
{#if grouped.local.length > 0}
|
||||||
|
<optgroup label="Local">
|
||||||
|
{#each grouped.local as model (model.id)}
|
||||||
|
<option value={model.id}>{model.id}</option>
|
||||||
|
{/each}
|
||||||
|
</optgroup>
|
||||||
|
{/if}
|
||||||
|
{#each Object.entries(grouped.peersByProvider).sort(([a], [b]) => a.localeCompare(b)) as [peerId, peerModels] (peerId)}
|
||||||
|
<optgroup label="Peer: {peerId}">
|
||||||
|
{#each peerModels as model (model.id)}
|
||||||
|
<option value={model.id}>{model.id}</option>
|
||||||
|
{/each}
|
||||||
|
</optgroup>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
interface Props {
|
||||||
|
featureName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { featureName }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center h-full">
|
||||||
|
<div class="text-center text-txtsecondary">
|
||||||
|
<p class="text-lg">{featureName}</p>
|
||||||
|
<p class="text-sm mt-2">To be implemented</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,373 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { models } from "../../stores/api";
|
||||||
|
import { persistentStore } from "../../stores/persistent";
|
||||||
|
import { generateSpeech } from "../../lib/speechApi";
|
||||||
|
import ModelSelector from "./ModelSelector.svelte";
|
||||||
|
import ExpandableTextarea from "./ExpandableTextarea.svelte";
|
||||||
|
|
||||||
|
const selectedModelStore = persistentStore<string>("playground-speech-model", "");
|
||||||
|
const selectedVoiceStore = persistentStore<string>("playground-speech-voice", "coral");
|
||||||
|
const autoPlayStore = persistentStore<boolean>("playground-speech-autoplay", false);
|
||||||
|
|
||||||
|
let inputText = $state("");
|
||||||
|
let isGenerating = $state(false);
|
||||||
|
let generatedAudioUrl = $state<string | null>(null);
|
||||||
|
let generatedText = $state<string | null>(null);
|
||||||
|
let generatedVoice = $state<string | null>(null);
|
||||||
|
let generatedTimestamp = $state<Date | null>(null);
|
||||||
|
let error = $state<string | null>(null);
|
||||||
|
let abortController = $state<AbortController | null>(null);
|
||||||
|
let audioElement = $state<HTMLAudioElement | null>(null);
|
||||||
|
let availableVoices = $state<string[]>(["coral", "alloy", "echo", "fable", "onyx", "nova", "shimmer"]);
|
||||||
|
let isLoadingVoices = $state(false);
|
||||||
|
|
||||||
|
// Default voices to fall back to if API call fails
|
||||||
|
const defaultVoices = ["coral", "alloy", "echo", "fable", "onyx", "nova", "shimmer"];
|
||||||
|
const CACHE_KEY = "playground-speech-voices-cache";
|
||||||
|
|
||||||
|
// Load voices cache from localStorage
|
||||||
|
function getVoicesCache(): Record<string, string[]> {
|
||||||
|
if (typeof window === "undefined") return {};
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(CACHE_KEY);
|
||||||
|
return saved ? JSON.parse(saved) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save voices cache to localStorage
|
||||||
|
function saveVoicesCache(cache: Record<string, string[]>) {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(CACHE_KEY, JSON.stringify(cache));
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error saving voices cache", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// On page load, restore cached voices for the selected model if available
|
||||||
|
$effect(() => {
|
||||||
|
const model = $selectedModelStore;
|
||||||
|
|
||||||
|
if (isInitialLoad) {
|
||||||
|
isInitialLoad = false;
|
||||||
|
// If we have cached voices for this model, use them
|
||||||
|
const cache = getVoicesCache();
|
||||||
|
if (model && cache[model]) {
|
||||||
|
availableVoices = cache[model];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function refreshVoices() {
|
||||||
|
const model = $selectedModelStore;
|
||||||
|
if (!model || isLoadingVoices) return;
|
||||||
|
|
||||||
|
isLoadingVoices = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/v1/audio/voices?model=${encodeURIComponent(model)}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
// Fall back to default voices if API call fails
|
||||||
|
availableVoices = defaultVoices;
|
||||||
|
const cache = getVoicesCache();
|
||||||
|
cache[model] = defaultVoices;
|
||||||
|
saveVoicesCache(cache);
|
||||||
|
selectedVoiceStore.set(defaultVoices[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
// Expect response to be an array of voice strings or an object with a voices array
|
||||||
|
const voices = Array.isArray(data) ? data : (data.voices || defaultVoices);
|
||||||
|
const newVoices = voices.length > 0 ? voices : defaultVoices;
|
||||||
|
|
||||||
|
availableVoices = newVoices;
|
||||||
|
const cache = getVoicesCache();
|
||||||
|
cache[model] = newVoices;
|
||||||
|
saveVoicesCache(cache);
|
||||||
|
|
||||||
|
// Reset to first available voice
|
||||||
|
selectedVoiceStore.set(newVoices[0]);
|
||||||
|
} catch {
|
||||||
|
// Fall back to default voices on error
|
||||||
|
availableVoices = defaultVoices;
|
||||||
|
const cache = getVoicesCache();
|
||||||
|
cache[model] = defaultVoices;
|
||||||
|
saveVoicesCache(cache);
|
||||||
|
selectedVoiceStore.set(defaultVoices[0]);
|
||||||
|
} finally {
|
||||||
|
isLoadingVoices = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleVoiceChange(event: Event) {
|
||||||
|
const value = (event.target as HTMLSelectElement).value;
|
||||||
|
if (value === "(refresh)") {
|
||||||
|
refreshVoices();
|
||||||
|
} else {
|
||||||
|
selectedVoiceStore.set(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-play effect when new audio is generated
|
||||||
|
$effect(() => {
|
||||||
|
if (generatedAudioUrl && $autoPlayStore && audioElement) {
|
||||||
|
audioElement.load();
|
||||||
|
audioElement.play().catch(() => {
|
||||||
|
// Ignore auto-play errors (e.g., browser policy blocks)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function generate() {
|
||||||
|
const trimmedText = inputText.trim();
|
||||||
|
if (!trimmedText || !$selectedModelStore || isGenerating) return;
|
||||||
|
|
||||||
|
isGenerating = true;
|
||||||
|
error = null;
|
||||||
|
abortController = new AbortController();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const audioBlob = await generateSpeech(
|
||||||
|
$selectedModelStore,
|
||||||
|
trimmedText,
|
||||||
|
$selectedVoiceStore,
|
||||||
|
abortController.signal
|
||||||
|
);
|
||||||
|
|
||||||
|
// Revoke previous URL to prevent memory leaks
|
||||||
|
if (generatedAudioUrl) {
|
||||||
|
URL.revokeObjectURL(generatedAudioUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create object URL for the audio blob and store metadata
|
||||||
|
generatedAudioUrl = URL.createObjectURL(audioBlob);
|
||||||
|
generatedText = trimmedText;
|
||||||
|
generatedVoice = $selectedVoiceStore;
|
||||||
|
generatedTimestamp = new Date();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof Error && err.name === "AbortError") {
|
||||||
|
// User cancelled
|
||||||
|
} else {
|
||||||
|
error = err instanceof Error ? err.message : "An error occurred";
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
isGenerating = false;
|
||||||
|
abortController = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelGeneration() {
|
||||||
|
abortController?.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAudio() {
|
||||||
|
if (generatedAudioUrl) {
|
||||||
|
URL.revokeObjectURL(generatedAudioUrl);
|
||||||
|
}
|
||||||
|
generatedAudioUrl = null;
|
||||||
|
generatedText = null;
|
||||||
|
generatedVoice = null;
|
||||||
|
generatedTimestamp = null;
|
||||||
|
error = null;
|
||||||
|
inputText = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearInput() {
|
||||||
|
inputText = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadAudio() {
|
||||||
|
if (!generatedAudioUrl) return;
|
||||||
|
|
||||||
|
const timestamp = (generatedTimestamp || new Date()).toISOString().replace(/[:.]/g, '-').slice(0, -5);
|
||||||
|
const voice = generatedVoice || 'speech';
|
||||||
|
const filename = `${voice}-${timestamp}.mp3`;
|
||||||
|
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = generatedAudioUrl;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(date: Date): string {
|
||||||
|
return date.toLocaleString(undefined, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
hour12: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Enter" && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
generate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col h-full">
|
||||||
|
<!-- Model and voice selectors -->
|
||||||
|
<div class="shrink-0 flex gap-2 mb-4">
|
||||||
|
<ModelSelector bind:value={$selectedModelStore} placeholder="Select a speech model..." disabled={isGenerating} />
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<select
|
||||||
|
class="shrink-0 px-3 py-2 rounded border border-gray-200 dark:border-white/10 bg-surface focus:outline-none focus:ring-2 focus:ring-primary"
|
||||||
|
value={$selectedVoiceStore}
|
||||||
|
onchange={handleVoiceChange}
|
||||||
|
disabled={isGenerating || isLoadingVoices || !$selectedModelStore}
|
||||||
|
>
|
||||||
|
{#each availableVoices as voice (voice)}
|
||||||
|
<option value={voice}>{voice}</option>
|
||||||
|
{/each}
|
||||||
|
<option value="(refresh)">(refresh)</option>
|
||||||
|
</select>
|
||||||
|
{#if $selectedModelStore && !getVoicesCache()[$selectedModelStore]}
|
||||||
|
<button
|
||||||
|
class="btn shrink-0"
|
||||||
|
onclick={refreshVoices}
|
||||||
|
disabled={isLoadingVoices}
|
||||||
|
title={isLoadingVoices ? "Loading voices..." : "Load voices for this model"}
|
||||||
|
>
|
||||||
|
{#if isLoadingVoices}
|
||||||
|
<svg class="w-5 h-5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
{:else}
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||||
|
</svg>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty state for no models configured -->
|
||||||
|
{#if !hasModels}
|
||||||
|
<div class="flex-1 flex items-center justify-center text-txtsecondary">
|
||||||
|
<p>No models configured. Add models to your configuration to generate speech.</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Audio display area -->
|
||||||
|
<div class="shrink-0 mb-4 bg-surface border border-gray-200 dark:border-white/10 rounded p-4 md:p-6">
|
||||||
|
{#if isGenerating}
|
||||||
|
<div class="flex items-center justify-center text-txtsecondary py-8">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="inline-block w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin mb-2"></div>
|
||||||
|
<p>Generating speech...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="flex items-center justify-center py-8">
|
||||||
|
<div class="text-center text-red-500">
|
||||||
|
<p class="font-medium">Error</p>
|
||||||
|
<p class="text-sm mt-1">{error}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if generatedAudioUrl}
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<!-- Header with metadata and download -->
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div class="flex flex-wrap gap-3 text-sm text-txtsecondary">
|
||||||
|
{#if generatedVoice}
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"></path>
|
||||||
|
</svg>
|
||||||
|
{generatedVoice}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if generatedTimestamp}
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
{formatTimestamp(generatedTimestamp)}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn shrink-0"
|
||||||
|
onclick={downloadAudio}
|
||||||
|
title="Download audio file"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Audio player with larger controls -->
|
||||||
|
<div class="w-full">
|
||||||
|
<audio bind:this={audioElement} controls class="w-full h-12 md:h-16">
|
||||||
|
<source src={generatedAudioUrl} type="audio/mpeg" />
|
||||||
|
Your browser does not support the audio element.
|
||||||
|
</audio>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center justify-center text-txtsecondary py-8">
|
||||||
|
<div class="text-center">
|
||||||
|
<svg class="w-12 h-12 md:w-16 md:h-16 mx-auto mb-2 opacity-40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z"></path>
|
||||||
|
</svg>
|
||||||
|
<p>Enter text below to convert to speech</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Text input area -->
|
||||||
|
<div class="flex-1 flex flex-col md:flex-row gap-2 min-h-0">
|
||||||
|
<ExpandableTextarea
|
||||||
|
bind:value={inputText}
|
||||||
|
placeholder="Enter text to convert to speech..."
|
||||||
|
rows={8}
|
||||||
|
onkeydown={handleKeyDown}
|
||||||
|
disabled={isGenerating || !$selectedModelStore}
|
||||||
|
/>
|
||||||
|
<div class="shrink-0 flex md:flex-col gap-2">
|
||||||
|
{#if isGenerating}
|
||||||
|
<button class="btn bg-red-500 hover:bg-red-600 text-white flex-1 md:flex-none" onclick={cancelGeneration}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<button
|
||||||
|
class="btn bg-primary text-btn-primary-text hover:opacity-90 flex-1 md:flex-none"
|
||||||
|
onclick={generate}
|
||||||
|
disabled={!inputText.trim() || !$selectedModelStore}
|
||||||
|
>
|
||||||
|
Generate
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn flex-1 md:flex-none"
|
||||||
|
onclick={clearInput}
|
||||||
|
disabled={!inputText.trim()}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
<label class="flex items-center justify-center gap-2 text-sm cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={$autoPlayStore}
|
||||||
|
class="cursor-pointer"
|
||||||
|
/>
|
||||||
|
Auto-play
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@import "katex/dist/katex.min.css";
|
||||||
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
|
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { AudioTranscriptionResponse } from "./types";
|
||||||
|
|
||||||
|
export async function transcribeAudio(
|
||||||
|
model: string,
|
||||||
|
file: File,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<AudioTranscriptionResponse> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
formData.append("model", model);
|
||||||
|
|
||||||
|
const response = await fetch("/v1/audio/transcriptions", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`Audio API error: ${response.status} - ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import type { ChatMessage, ChatCompletionRequest } from "./types";
|
||||||
|
|
||||||
|
export interface StreamChunk {
|
||||||
|
content: string;
|
||||||
|
reasoning_content?: string;
|
||||||
|
done: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatOptions {
|
||||||
|
temperature?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseSSELine(line: string): StreamChunk | null {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (!trimmed || !trimmed.startsWith("data: ")) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = trimmed.slice(6);
|
||||||
|
if (data === "[DONE]") {
|
||||||
|
return { content: "", done: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
const delta = parsed.choices?.[0]?.delta;
|
||||||
|
const content = delta?.content || "";
|
||||||
|
const reasoning_content = delta?.reasoning_content || "";
|
||||||
|
|
||||||
|
if (content || reasoning_content) {
|
||||||
|
return { content, reasoning_content, done: false };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function* streamChatCompletion(
|
||||||
|
model: string,
|
||||||
|
messages: ChatMessage[],
|
||||||
|
signal?: AbortSignal,
|
||||||
|
options?: ChatOptions
|
||||||
|
): AsyncGenerator<StreamChunk> {
|
||||||
|
const request: ChatCompletionRequest = {
|
||||||
|
model,
|
||||||
|
messages,
|
||||||
|
stream: true,
|
||||||
|
temperature: options?.temperature,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch("/v1/chat/completions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`Chat API error: ${response.status} - ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) {
|
||||||
|
throw new Error("Response body is not readable");
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split("\n");
|
||||||
|
buffer = lines.pop() || "";
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const result = parseSSELine(line);
|
||||||
|
if (result?.done) {
|
||||||
|
yield result;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result) {
|
||||||
|
yield result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process any remaining buffer
|
||||||
|
const result = parseSSELine(buffer);
|
||||||
|
if (result && !result.done) {
|
||||||
|
yield result;
|
||||||
|
}
|
||||||
|
|
||||||
|
yield { content: "", done: true };
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import type { ImageGenerationRequest, ImageGenerationResponse } from "./types";
|
||||||
|
|
||||||
|
export async function generateImage(
|
||||||
|
model: string,
|
||||||
|
prompt: string,
|
||||||
|
size: string,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<ImageGenerationResponse> {
|
||||||
|
const request: ImageGenerationRequest = {
|
||||||
|
model,
|
||||||
|
prompt,
|
||||||
|
n: 1,
|
||||||
|
size,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch("/v1/images/generations", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`Image API error: ${response.status} - ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { renderMarkdown, escapeHtml } from "./markdown";
|
||||||
|
|
||||||
|
describe("renderMarkdown", () => {
|
||||||
|
describe("basic markdown", () => {
|
||||||
|
it("renders plain text", () => {
|
||||||
|
const result = renderMarkdown("Hello world");
|
||||||
|
expect(result).toContain("Hello world");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders bold text", () => {
|
||||||
|
const result = renderMarkdown("**bold**");
|
||||||
|
expect(result).toContain("<strong>bold</strong>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders italic text", () => {
|
||||||
|
const result = renderMarkdown("*italic*");
|
||||||
|
expect(result).toContain("<em>italic</em>");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders code blocks", () => {
|
||||||
|
const result = renderMarkdown("```js\nconst x = 1;\n```");
|
||||||
|
expect(result).toContain("hljs");
|
||||||
|
expect(result).toContain("const");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for empty content", () => {
|
||||||
|
const result = renderMarkdown("");
|
||||||
|
expect(result).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty string for null/undefined content", () => {
|
||||||
|
// @ts-expect-error - testing null input
|
||||||
|
expect(renderMarkdown(null)).toBe("");
|
||||||
|
// @ts-expect-error - testing undefined input
|
||||||
|
expect(renderMarkdown(undefined)).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("KaTeX math rendering", () => {
|
||||||
|
it("renders inline math with $...$ syntax", () => {
|
||||||
|
const result = renderMarkdown("The equation $E = mc^2$ is famous.");
|
||||||
|
// KaTeX should convert this to HTML with katex class
|
||||||
|
expect(result).toContain("katex");
|
||||||
|
expect(result).toContain("E");
|
||||||
|
expect(result).toContain("=");
|
||||||
|
expect(result).toContain("mc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders display math with $$...$$ syntax", () => {
|
||||||
|
const result = renderMarkdown("$$\\int_{a}^{b} f(x) dx$$");
|
||||||
|
// Math should be rendered with KaTeX
|
||||||
|
expect(result).toContain("katex");
|
||||||
|
expect(result).toContain("∫");
|
||||||
|
expect(result).toContain("f(x)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders complex LaTeX expressions", () => {
|
||||||
|
const result = renderMarkdown("$$\\sum_{i=1}^{n} x_i = \\frac{1}{n}\\sum_{i=1}^{n} x_i$$");
|
||||||
|
expect(result).toContain("katex");
|
||||||
|
expect(result).toContain("∑"); // or the MathML equivalent
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders LaTeX with Greek letters", () => {
|
||||||
|
const result = renderMarkdown("$\\alpha + \\beta = \\gamma$");
|
||||||
|
expect(result).toContain("katex");
|
||||||
|
// Greek letters should be rendered
|
||||||
|
expect(result).toMatch(/[αβγ]|alpha|beta|gamma/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders LaTeX with fractions", () => {
|
||||||
|
const result = renderMarkdown("$\\frac{a}{b}$");
|
||||||
|
expect(result).toContain("katex");
|
||||||
|
expect(result).toContain("frac");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders LaTeX with subscripts and superscripts", () => {
|
||||||
|
const result = renderMarkdown("$x^2 + y_3$");
|
||||||
|
expect(result).toContain("katex");
|
||||||
|
expect(result).toContain("sup"); // superscript
|
||||||
|
expect(result).toContain("sub"); // subscript
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders multiple inline math expressions in one paragraph", () => {
|
||||||
|
const result = renderMarkdown("First $x = 1$ and then $y = 2$.");
|
||||||
|
// Should contain multiple katex spans
|
||||||
|
const katexMatches = result.match(/katex/g);
|
||||||
|
expect(katexMatches?.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders math within a larger markdown document", () => {
|
||||||
|
const markdown = `# Heading
|
||||||
|
|
||||||
|
This is a paragraph with $E = mc^2$ inline math.
|
||||||
|
|
||||||
|
$$\\int_0^\\infty e^{-x} dx = 1$$
|
||||||
|
|
||||||
|
More text here.
|
||||||
|
`;
|
||||||
|
const result = renderMarkdown(markdown);
|
||||||
|
expect(result).toContain("<h1>Heading</h1>");
|
||||||
|
expect(result).toContain("katex");
|
||||||
|
// Both inline and display math should be rendered
|
||||||
|
expect(result).toContain("E = mc");
|
||||||
|
expect(result).toContain("∫");
|
||||||
|
expect(result).toContain("∞");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles escaped dollar signs", () => {
|
||||||
|
const result = renderMarkdown("This costs \\$5 and $x = 1$.");
|
||||||
|
// Should render the escaped $5 as text and the math
|
||||||
|
expect(result).toContain("katex");
|
||||||
|
expect(result).toContain("$5");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty math expressions gracefully", () => {
|
||||||
|
// Empty math should not break the renderer
|
||||||
|
const result = renderMarkdown("$$$");
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders LaTeX matrices", () => {
|
||||||
|
const result = renderMarkdown("$$\\begin{pmatrix} a & b \\\\ c & d \\end{pmatrix}$$");
|
||||||
|
expect(result).toContain("katex");
|
||||||
|
expect(result).toContain("pmatrix");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders LaTeX square roots", () => {
|
||||||
|
const result = renderMarkdown("$\\sqrt{x^2 + y^2}$");
|
||||||
|
expect(result).toContain("katex");
|
||||||
|
expect(result).toContain("sqrt");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("escapeHtml", () => {
|
||||||
|
it("escapes HTML entities", () => {
|
||||||
|
expect(escapeHtml("<script>")).toBe("<script>");
|
||||||
|
expect(escapeHtml('"quoted"')).toBe(""quoted"");
|
||||||
|
expect(escapeHtml("'single'")).toBe("'single'");
|
||||||
|
expect(escapeHtml("a & b")).toBe("a & b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty string", () => {
|
||||||
|
expect(escapeHtml("")).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("error handling", () => {
|
||||||
|
it("does not throw on invalid LaTeX syntax", () => {
|
||||||
|
// Invalid LaTeX should not crash the renderer
|
||||||
|
expect(() => renderMarkdown("$\\invalidcommand{")).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns fallback HTML on processing errors", () => {
|
||||||
|
// Very large or malformed input should be handled
|
||||||
|
const result = renderMarkdown("$" + "a".repeat(10000) + "$");
|
||||||
|
expect(result).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { unified } from "unified";
|
||||||
|
import remarkParse from "remark-parse";
|
||||||
|
import remarkGfm from "remark-gfm";
|
||||||
|
import remarkMath from "remark-math";
|
||||||
|
import remarkRehype from "remark-rehype";
|
||||||
|
import rehypeKatex from "rehype-katex";
|
||||||
|
import rehypeStringify from "rehype-stringify";
|
||||||
|
import hljs from "highlight.js";
|
||||||
|
import { visit } from "unist-util-visit";
|
||||||
|
import type { Element, Root } from "hast";
|
||||||
|
|
||||||
|
// Custom plugin to highlight code blocks with highlight.js
|
||||||
|
function rehypeHighlight() {
|
||||||
|
return (tree: Root) => {
|
||||||
|
visit(tree, "element", (node: Element) => {
|
||||||
|
if (node.tagName === "code" && node.properties) {
|
||||||
|
const className = node.properties.className;
|
||||||
|
const classes = Array.isArray(className)
|
||||||
|
? className.filter((c): c is string => typeof c === "string")
|
||||||
|
: [];
|
||||||
|
const lang = classes
|
||||||
|
.find((c) => c.startsWith("language-"))
|
||||||
|
?.replace("language-", "");
|
||||||
|
|
||||||
|
const text = node.children
|
||||||
|
.filter((child): child is { type: "text"; value: string } => child.type === "text")
|
||||||
|
.map((child) => child.value)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
if (text) {
|
||||||
|
const language = lang && hljs.getLanguage(lang) ? lang : "plaintext";
|
||||||
|
const highlighted = hljs.highlight(text, { language }).value;
|
||||||
|
|
||||||
|
// Replace the text node with raw HTML
|
||||||
|
node.properties.className = [
|
||||||
|
"hljs",
|
||||||
|
`language-${language}`,
|
||||||
|
...classes.filter((c) => !c.startsWith("language-")),
|
||||||
|
];
|
||||||
|
// Use type assertion since we're modifying the tree structure
|
||||||
|
(node.children as unknown) = [
|
||||||
|
{ type: "raw", value: highlighted },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function escapeHtml(text: string): string {
|
||||||
|
const htmlEntities: Record<string, string> = {
|
||||||
|
"&": "&",
|
||||||
|
"<": "<",
|
||||||
|
">": ">",
|
||||||
|
'"': """,
|
||||||
|
"'": "'",
|
||||||
|
};
|
||||||
|
return text.replace(/[&<>"']/g, (char) => htmlEntities[char]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the unified processor
|
||||||
|
const processor = unified()
|
||||||
|
.use(remarkParse)
|
||||||
|
.use(remarkGfm)
|
||||||
|
.use(remarkMath)
|
||||||
|
.use(remarkRehype, { allowDangerousHtml: true })
|
||||||
|
.use(rehypeKatex)
|
||||||
|
.use(rehypeHighlight)
|
||||||
|
.use(rehypeStringify, { allowDangerousHtml: true });
|
||||||
|
|
||||||
|
export function renderMarkdown(content: string): string {
|
||||||
|
if (!content) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = processor.processSync(content);
|
||||||
|
return String(result);
|
||||||
|
} catch {
|
||||||
|
// Fallback to escaped plain text if markdown parsing fails
|
||||||
|
return `<p>${escapeHtml(content)}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { Model } from "./types";
|
||||||
|
|
||||||
|
export interface GroupedModels {
|
||||||
|
local: Model[];
|
||||||
|
peersByProvider: Record<string, Model[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupModels(models: Model[]): GroupedModels {
|
||||||
|
const available = models.filter((m) => !m.unlisted);
|
||||||
|
const local = available.filter((m) => !m.peerID);
|
||||||
|
const peerModels = available.filter((m) => m.peerID);
|
||||||
|
|
||||||
|
const peersByProvider = peerModels.reduce(
|
||||||
|
(acc, model) => {
|
||||||
|
const peerId = model.peerID || "unknown";
|
||||||
|
if (!acc[peerId]) acc[peerId] = [];
|
||||||
|
acc[peerId].push(model);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, Model[]>
|
||||||
|
);
|
||||||
|
|
||||||
|
return { local, peersByProvider };
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import type { SpeechGenerationRequest } from "./types";
|
||||||
|
|
||||||
|
export async function generateSpeech(
|
||||||
|
model: string,
|
||||||
|
input: string,
|
||||||
|
voice: string,
|
||||||
|
signal?: AbortSignal
|
||||||
|
): Promise<Blob> {
|
||||||
|
const request: SpeechGenerationRequest = {
|
||||||
|
model,
|
||||||
|
input,
|
||||||
|
voice,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch("/v1/audio/speech", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(request),
|
||||||
|
signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(`Speech API error: ${response.status} - ${errorText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.blob();
|
||||||
|
}
|
||||||
@@ -40,3 +40,77 @@ export interface VersionInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type ScreenWidth = "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
|
export type ScreenWidth = "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
|
||||||
|
|
||||||
|
export type TextContentPart = {
|
||||||
|
type: "text";
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ImageContentPart = {
|
||||||
|
type: "image_url";
|
||||||
|
image_url: { url: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ContentPart = TextContentPart | ImageContentPart;
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
role: "user" | "assistant" | "system";
|
||||||
|
content: string | ContentPart[];
|
||||||
|
reasoning_content?: string;
|
||||||
|
reasoningTimeMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTextContent(content: string | ContentPart[]): string {
|
||||||
|
if (typeof content === "string") {
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
const textParts = content.filter((part): part is TextContentPart => part.type === "text");
|
||||||
|
return textParts.map((part) => part.text).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getImageUrls(content: string | ContentPart[]): string[] {
|
||||||
|
if (typeof content === "string") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return content
|
||||||
|
.filter((part): part is ImageContentPart => part.type === "image_url")
|
||||||
|
.map((part) => part.image_url.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChatCompletionRequest {
|
||||||
|
model: string;
|
||||||
|
messages: ChatMessage[];
|
||||||
|
stream: boolean;
|
||||||
|
temperature?: number;
|
||||||
|
max_tokens?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageGenerationRequest {
|
||||||
|
model: string;
|
||||||
|
prompt: string;
|
||||||
|
n?: number;
|
||||||
|
size?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageGenerationResponse {
|
||||||
|
created: number;
|
||||||
|
data: Array<{
|
||||||
|
url?: string;
|
||||||
|
b64_json?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AudioTranscriptionRequest {
|
||||||
|
file: File;
|
||||||
|
model: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AudioTranscriptionResponse {
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SpeechGenerationRequest {
|
||||||
|
model: string;
|
||||||
|
input: string;
|
||||||
|
voice: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
import "highlight.js/styles/github-dark.css";
|
||||||
import App from "./App.svelte";
|
import App from "./App.svelte";
|
||||||
import { mount } from "svelte";
|
import { mount } from "svelte";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { persistentStore } from "../stores/persistent";
|
||||||
|
import ChatInterface from "../components/playground/ChatInterface.svelte";
|
||||||
|
import ImageInterface from "../components/playground/ImageInterface.svelte";
|
||||||
|
import AudioInterface from "../components/playground/AudioInterface.svelte";
|
||||||
|
import SpeechInterface from "../components/playground/SpeechInterface.svelte";
|
||||||
|
|
||||||
|
type Tab = "chat" | "images" | "speech" | "audio";
|
||||||
|
|
||||||
|
const selectedTabStore = persistentStore<Tab>("playground-selected-tab", "chat");
|
||||||
|
let mobileMenuOpen = $state(false);
|
||||||
|
|
||||||
|
const tabs: { id: Tab; label: string }[] = [
|
||||||
|
{ id: "chat", label: "Chat" },
|
||||||
|
{ id: "images", label: "Images" },
|
||||||
|
{ id: "speech", label: "Speech" },
|
||||||
|
{ id: "audio", label: "Transcription" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function selectTab(tab: Tab) {
|
||||||
|
selectedTabStore.set(tab);
|
||||||
|
mobileMenuOpen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTabLabel(tabId: Tab): string {
|
||||||
|
return tabs.find(t => t.id === tabId)?.label || "";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="card h-full flex flex-col">
|
||||||
|
<!-- Tab navigation -->
|
||||||
|
<div class="shrink-0 mb-4">
|
||||||
|
<!-- Mobile: Dropdown menu (hidden on md and up) -->
|
||||||
|
<div class="block md:hidden relative">
|
||||||
|
<button
|
||||||
|
class="w-full px-4 py-2 rounded font-medium transition-colors flex items-center justify-between bg-surface hover:bg-secondary-hover border border-gray-200 dark:border-white/10"
|
||||||
|
onclick={() => (mobileMenuOpen = !mobileMenuOpen)}
|
||||||
|
>
|
||||||
|
<span>{getTabLabel($selectedTabStore)}</span>
|
||||||
|
<svg
|
||||||
|
class="w-5 h-5 transition-transform {mobileMenuOpen ? 'rotate-180' : ''}"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{#if mobileMenuOpen}
|
||||||
|
<div class="absolute top-full left-0 right-0 mt-1 bg-surface border border-gray-200 dark:border-white/10 rounded shadow-lg z-10">
|
||||||
|
{#each tabs as tab (tab.id)}
|
||||||
|
<button
|
||||||
|
class="w-full px-4 py-2 text-left hover:bg-secondary-hover transition-colors first:rounded-t last:rounded-b {$selectedTabStore === tab.id ? 'bg-primary/10 font-medium' : ''}"
|
||||||
|
onclick={() => selectTab(tab.id)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Desktop: Tab buttons (shown on md and up) -->
|
||||||
|
<div class="hidden md:flex flex-wrap gap-2">
|
||||||
|
{#each tabs as tab (tab.id)}
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 rounded font-medium transition-colors {$selectedTabStore === tab.id
|
||||||
|
? 'bg-primary text-btn-primary-text'
|
||||||
|
: 'bg-surface hover:bg-secondary-hover border border-gray-200 dark:border-white/10'}"
|
||||||
|
onclick={() => selectTab(tab.id)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab content -->
|
||||||
|
<div class="flex-1 overflow-hidden relative">
|
||||||
|
<div class="h-full" class:tab-hidden={$selectedTabStore !== "chat"}>
|
||||||
|
<ChatInterface />
|
||||||
|
</div>
|
||||||
|
<div class="h-full" class:tab-hidden={$selectedTabStore !== "images"}>
|
||||||
|
<ImageInterface />
|
||||||
|
</div>
|
||||||
|
<div class="h-full" class:tab-hidden={$selectedTabStore !== "speech"}>
|
||||||
|
<SpeechInterface />
|
||||||
|
</div>
|
||||||
|
<div class="h-full" class:tab-hidden={$selectedTabStore !== "audio"}>
|
||||||
|
<AudioInterface />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.tab-hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,10 +1,25 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
import { compression } from "vite-plugin-compression2";
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [svelte(), tailwindcss()],
|
plugins: [
|
||||||
|
svelte(),
|
||||||
|
tailwindcss(),
|
||||||
|
compression({
|
||||||
|
algorithm: "gzip",
|
||||||
|
exclude: [/\.(br)$/, /\.(gz)$/],
|
||||||
|
threshold: 1024,
|
||||||
|
}),
|
||||||
|
compression({
|
||||||
|
algorithm: "brotliCompress",
|
||||||
|
exclude: [/\.(br)$/, /\.(gz)$/],
|
||||||
|
threshold: 1024,
|
||||||
|
filename: "[path][base].br",
|
||||||
|
}),
|
||||||
|
],
|
||||||
base: "/ui/",
|
base: "/ui/",
|
||||||
build: {
|
build: {
|
||||||
outDir: "../proxy/ui_dist",
|
outDir: "../proxy/ui_dist",
|
||||||
@@ -16,6 +31,7 @@ export default defineConfig({
|
|||||||
"/logs": "http://localhost:8080",
|
"/logs": "http://localhost:8080",
|
||||||
"/upstream": "http://localhost:8080",
|
"/upstream": "http://localhost:8080",
|
||||||
"/unload": "http://localhost:8080",
|
"/unload": "http://localhost:8080",
|
||||||
|
"/v1": "http://localhost:8080",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
.vite
|
|
||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
node_modules
|
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
*.local
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
|
||||||
.DS_Store
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
# React + TypeScript + Vite
|
|
||||||
|
|
||||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
|
||||||
|
|
||||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
|
||||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
|
||||||
|
|
||||||
## Expanding the ESLint configuration
|
|
||||||
|
|
||||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
|
||||||
|
|
||||||
```js
|
|
||||||
export default tseslint.config({
|
|
||||||
extends: [
|
|
||||||
// Remove ...tseslint.configs.recommended and replace with this
|
|
||||||
...tseslint.configs.recommendedTypeChecked,
|
|
||||||
// Alternatively, use this for stricter rules
|
|
||||||
...tseslint.configs.strictTypeChecked,
|
|
||||||
// Optionally, add this for stylistic rules
|
|
||||||
...tseslint.configs.stylisticTypeChecked,
|
|
||||||
],
|
|
||||||
languageOptions: {
|
|
||||||
// other options...
|
|
||||||
parserOptions: {
|
|
||||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
|
||||||
tsconfigRootDir: import.meta.dirname,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
|
||||||
|
|
||||||
```js
|
|
||||||
// eslint.config.js
|
|
||||||
import reactX from 'eslint-plugin-react-x'
|
|
||||||
import reactDom from 'eslint-plugin-react-dom'
|
|
||||||
|
|
||||||
export default tseslint.config({
|
|
||||||
plugins: {
|
|
||||||
// Add the react-x and react-dom plugins
|
|
||||||
'react-x': reactX,
|
|
||||||
'react-dom': reactDom,
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
// other rules...
|
|
||||||
// Enable its recommended typescript rules
|
|
||||||
...reactX.configs['recommended-typescript'].rules,
|
|
||||||
...reactDom.configs.recommended.rules,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
```
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import js from '@eslint/js'
|
|
||||||
import globals from 'globals'
|
|
||||||
import reactHooks from 'eslint-plugin-react-hooks'
|
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
|
||||||
import tseslint from 'typescript-eslint'
|
|
||||||
|
|
||||||
export default tseslint.config(
|
|
||||||
{ ignores: ['dist'] },
|
|
||||||
{
|
|
||||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
|
||||||
files: ['**/*.{ts,tsx}'],
|
|
||||||
languageOptions: {
|
|
||||||
ecmaVersion: 2020,
|
|
||||||
globals: globals.browser,
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
'react-hooks': reactHooks,
|
|
||||||
'react-refresh': reactRefresh,
|
|
||||||
},
|
|
||||||
rules: {
|
|
||||||
...reactHooks.configs.recommended.rules,
|
|
||||||
'react-refresh/only-export-components': [
|
|
||||||
'warn',
|
|
||||||
{ allowConstantExport: true },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
||||||
<link rel="shortcut icon" href="/favicon.ico" />
|
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
|
||||||
<link rel="manifest" href="/site.webmanifest" />
|
|
||||||
<title>llama-swap</title>
|
|
||||||
</head>
|
|
||||||
<body >
|
|
||||||
<div id="root"></div>
|
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "ui",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"start": "vite",
|
|
||||||
"build": "tsc -b && vite build --emptyOutDir",
|
|
||||||
"lint": "eslint .",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"react": "^19.1.0",
|
|
||||||
"react-dom": "^19.1.0",
|
|
||||||
"react-icons": "^5.5.0",
|
|
||||||
"react-resizable-panels": "^3.0.4",
|
|
||||||
"react-router-dom": "^7.12.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@eslint/js": "^9.25.0",
|
|
||||||
"@tailwindcss/vite": "^4.1.8",
|
|
||||||
"@types/react": "^19.1.2",
|
|
||||||
"@types/react-dom": "^19.1.2",
|
|
||||||
"@vitejs/plugin-react": "^4.4.1",
|
|
||||||
"eslint": "^9.25.0",
|
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
|
||||||
"eslint-plugin-react-refresh": "^0.4.19",
|
|
||||||
"globals": "^16.0.0",
|
|
||||||
"tailwindcss": "^4.1.8",
|
|
||||||
"typescript": "~5.8.3",
|
|
||||||
"typescript-eslint": "^8.30.1",
|
|
||||||
"vite": "^6.3.5"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 38 KiB |
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "llama-swap",
|
|
||||||
"short_name": "llama-swap",
|
|
||||||
"icons": [
|
|
||||||
{
|
|
||||||
"src": "/web-app-manifest-192x192.png",
|
|
||||||
"sizes": "192x192",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "maskable"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "/web-app-manifest-512x512.png",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "maskable"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"theme_color": "#ffffff",
|
|
||||||
"background_color": "#ffffff",
|
|
||||||
"display": "standalone"
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 28 KiB |
@@ -1,6 +0,0 @@
|
|||||||
#root {
|
|
||||||
max-width: 1280px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
import { useEffect } from "react";
|
|
||||||
import { Navigate, Route, BrowserRouter as Router, Routes } from "react-router-dom";
|
|
||||||
import { Header } from "./components/Header";
|
|
||||||
import { useAPI } from "./contexts/APIProvider";
|
|
||||||
import { useTheme } from "./contexts/ThemeProvider";
|
|
||||||
import ActivityPage from "./pages/Activity";
|
|
||||||
import LogViewerPage from "./pages/LogViewer";
|
|
||||||
import ModelPage from "./pages/Models";
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
const { setConnectionState } = useTheme();
|
|
||||||
|
|
||||||
const { connectionStatus } = useAPI();
|
|
||||||
|
|
||||||
// Synchronize the window.title connections state with the actual connection state
|
|
||||||
useEffect(() => {
|
|
||||||
setConnectionState(connectionStatus);
|
|
||||||
}, [connectionStatus]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Router basename="/ui/">
|
|
||||||
<div className="flex flex-col h-screen">
|
|
||||||
<Header />
|
|
||||||
|
|
||||||
<main className="flex-1 overflow-auto p-4">
|
|
||||||
<Routes>
|
|
||||||
<Route path="/" element={<LogViewerPage />} />
|
|
||||||
<Route path="/models" element={<ModelPage />} />
|
|
||||||
<Route path="/activity" element={<ActivityPage />} />
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
|
||||||
</Routes>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</Router>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
||||||
|
Before Width: | Height: | Size: 12 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 4.0 KiB |
@@ -1,26 +0,0 @@
|
|||||||
import { useAPI } from "../contexts/APIProvider";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
const ConnectionStatusIcon = () => {
|
|
||||||
const { connectionStatus, versionInfo } = useAPI();
|
|
||||||
|
|
||||||
const eventStatusColor = useMemo(() => {
|
|
||||||
switch (connectionStatus) {
|
|
||||||
case "connected":
|
|
||||||
return "bg-emerald-500";
|
|
||||||
case "connecting":
|
|
||||||
return "bg-amber-500";
|
|
||||||
case "disconnected":
|
|
||||||
default:
|
|
||||||
return "bg-red-500";
|
|
||||||
}
|
|
||||||
}, [connectionStatus]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center" title={`Event Stream: ${connectionStatus ?? 'unknown'}\nAPI Version: ${versionInfo?.version ?? 'unknown'}\nCommit Hash: ${versionInfo?.commit?.substring(0,7) ?? 'unknown'}\nBuild Date: ${versionInfo?.build_date ?? 'unknown'}`}>
|
|
||||||
<span className={`inline-block w-3 h-3 rounded-full ${eventStatusColor} mr-2`}></span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ConnectionStatusIcon;
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { useCallback } from "react";
|
|
||||||
import { RiMoonFill, RiSunFill } from "react-icons/ri";
|
|
||||||
import { NavLink, type NavLinkRenderProps } from "react-router-dom";
|
|
||||||
import { useTheme } from "../contexts/ThemeProvider";
|
|
||||||
import ConnectionStatusIcon from "./ConnectionStatus";
|
|
||||||
|
|
||||||
export function Header() {
|
|
||||||
const { screenWidth, toggleTheme, isDarkMode, appTitle, setAppTitle, isNarrow } = useTheme();
|
|
||||||
const handleTitleChange = useCallback(
|
|
||||||
(newTitle: string) => {
|
|
||||||
setAppTitle(newTitle.replace(/\n/g, "").trim().substring(0, 64) || "llama-swap");
|
|
||||||
},
|
|
||||||
[setAppTitle]
|
|
||||||
);
|
|
||||||
|
|
||||||
const navLinkClass = ({ isActive }: NavLinkRenderProps) =>
|
|
||||||
`text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 ${isActive ? "font-semibold" : ""}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<header className={`flex items-center justify-between bg-surface border-b border-border px-4 ${isNarrow ? "py-1 h-[60px]" : "p-2 h-[75px]"}`}>
|
|
||||||
{screenWidth !== "xs" && screenWidth !== "sm" && (
|
|
||||||
<h1
|
|
||||||
contentEditable
|
|
||||||
suppressContentEditableWarning
|
|
||||||
className="p-0 outline-none hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
|
|
||||||
onBlur={(e) => handleTitleChange(e.currentTarget.textContent || "(set title)")}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
handleTitleChange(e.currentTarget.textContent || "(set title)");
|
|
||||||
e.currentTarget.blur();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{appTitle}
|
|
||||||
</h1>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<menu className="flex items-center gap-4">
|
|
||||||
<NavLink to="/" className={navLinkClass} type="button">
|
|
||||||
Logs
|
|
||||||
</NavLink>
|
|
||||||
<NavLink to="/models" className={navLinkClass} type="button">
|
|
||||||
Models
|
|
||||||
</NavLink>
|
|
||||||
<NavLink to="/activity" className={navLinkClass} type="button">
|
|
||||||
Activity
|
|
||||||
</NavLink>
|
|
||||||
<button className="" onClick={toggleTheme}>
|
|
||||||
{isDarkMode ? <RiMoonFill /> : <RiSunFill />}
|
|
||||||
</button>
|
|
||||||
<ConnectionStatusIcon />
|
|
||||||
</menu>
|
|
||||||
</header>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,293 +0,0 @@
|
|||||||
import { createContext, useState, useContext, useEffect, useCallback, useMemo, type ReactNode } from "react";
|
|
||||||
import type { ConnectionState } from "../lib/types";
|
|
||||||
|
|
||||||
type ModelStatus = "ready" | "starting" | "stopping" | "stopped" | "shutdown" | "unknown";
|
|
||||||
const LOG_LENGTH_LIMIT = 1024 * 100; /* 100KB of log data */
|
|
||||||
|
|
||||||
export interface Model {
|
|
||||||
id: string;
|
|
||||||
state: ModelStatus;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
unlisted: boolean;
|
|
||||||
peerID: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface APIProviderType {
|
|
||||||
models: Model[];
|
|
||||||
listModels: () => Promise<Model[]>;
|
|
||||||
unloadAllModels: () => Promise<void>;
|
|
||||||
unloadSingleModel: (model: string) => Promise<void>;
|
|
||||||
loadModel: (model: string) => Promise<void>;
|
|
||||||
enableAPIEvents: (enabled: boolean) => void;
|
|
||||||
proxyLogs: string;
|
|
||||||
upstreamLogs: string;
|
|
||||||
metrics: Metrics[];
|
|
||||||
connectionStatus: ConnectionState;
|
|
||||||
versionInfo: VersionInfo;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Metrics {
|
|
||||||
id: number;
|
|
||||||
timestamp: string;
|
|
||||||
model: string;
|
|
||||||
cache_tokens: number;
|
|
||||||
input_tokens: number;
|
|
||||||
output_tokens: number;
|
|
||||||
prompt_per_second: number;
|
|
||||||
tokens_per_second: number;
|
|
||||||
duration_ms: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LogData {
|
|
||||||
source: "upstream" | "proxy";
|
|
||||||
data: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface APIEventEnvelope {
|
|
||||||
type: "modelStatus" | "logData" | "metrics";
|
|
||||||
data: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VersionInfo {
|
|
||||||
build_date: string;
|
|
||||||
commit: string;
|
|
||||||
version: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const APIContext = createContext<APIProviderType | undefined>(undefined);
|
|
||||||
type APIProviderProps = {
|
|
||||||
children: ReactNode;
|
|
||||||
autoStartAPIEvents?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
let apiEventSource: EventSource | null = null;
|
|
||||||
|
|
||||||
export function APIProvider({ children, autoStartAPIEvents = true }: APIProviderProps) {
|
|
||||||
const [proxyLogs, setProxyLogs] = useState("");
|
|
||||||
const [upstreamLogs, setUpstreamLogs] = useState("");
|
|
||||||
const [metrics, setMetrics] = useState<Metrics[]>([]);
|
|
||||||
const [connectionStatus, setConnectionState] = useState<ConnectionState>("disconnected");
|
|
||||||
const [versionInfo, setVersionInfo] = useState<VersionInfo>({
|
|
||||||
build_date: "unknown",
|
|
||||||
commit: "unknown",
|
|
||||||
version: "unknown",
|
|
||||||
});
|
|
||||||
//const apiEventSource = useRef<EventSource | null>(null);
|
|
||||||
|
|
||||||
const [models, setModels] = useState<Model[]>([]);
|
|
||||||
|
|
||||||
const appendLog = useCallback((newData: string, setter: React.Dispatch<React.SetStateAction<string>>) => {
|
|
||||||
setter((prev) => {
|
|
||||||
const updatedLog = prev + newData;
|
|
||||||
return updatedLog.length > LOG_LENGTH_LIMIT ? updatedLog.slice(-LOG_LENGTH_LIMIT) : updatedLog;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const enableAPIEvents = useCallback((enabled: boolean) => {
|
|
||||||
if (!enabled) {
|
|
||||||
apiEventSource?.close();
|
|
||||||
apiEventSource = null;
|
|
||||||
setMetrics([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let retryCount = 0;
|
|
||||||
const initialDelay = 1000; // 1 second
|
|
||||||
|
|
||||||
const connect = () => {
|
|
||||||
apiEventSource?.close();
|
|
||||||
apiEventSource = new EventSource("/api/events");
|
|
||||||
|
|
||||||
setConnectionState("connecting");
|
|
||||||
|
|
||||||
apiEventSource.onopen = () => {
|
|
||||||
// clear everything out on connect to keep things in sync
|
|
||||||
setProxyLogs("");
|
|
||||||
setUpstreamLogs("");
|
|
||||||
setMetrics([]); // clear metrics on reconnect
|
|
||||||
setModels([]); // clear models on reconnect
|
|
||||||
retryCount = 0;
|
|
||||||
setConnectionState("connected");
|
|
||||||
};
|
|
||||||
|
|
||||||
apiEventSource.onmessage = (e: MessageEvent) => {
|
|
||||||
try {
|
|
||||||
const message = JSON.parse(e.data) as APIEventEnvelope;
|
|
||||||
switch (message.type) {
|
|
||||||
case "modelStatus":
|
|
||||||
{
|
|
||||||
const models = JSON.parse(message.data) as Model[];
|
|
||||||
|
|
||||||
// sort models by name and id
|
|
||||||
models.sort((a, b) => {
|
|
||||||
return (a.name + a.id).localeCompare(b.name + b.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
setModels(models);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "logData":
|
|
||||||
const logData = JSON.parse(message.data) as LogData;
|
|
||||||
switch (logData.source) {
|
|
||||||
case "proxy":
|
|
||||||
appendLog(logData.data, setProxyLogs);
|
|
||||||
break;
|
|
||||||
case "upstream":
|
|
||||||
appendLog(logData.data, setUpstreamLogs);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "metrics":
|
|
||||||
{
|
|
||||||
const newMetrics = JSON.parse(message.data) as Metrics[];
|
|
||||||
setMetrics((prevMetrics) => {
|
|
||||||
return [...newMetrics, ...prevMetrics];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error(e.data, err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
apiEventSource.onerror = () => {
|
|
||||||
apiEventSource?.close();
|
|
||||||
retryCount++;
|
|
||||||
const delay = Math.min(initialDelay * Math.pow(2, retryCount - 1), 5000);
|
|
||||||
setConnectionState("disconnected");
|
|
||||||
setTimeout(connect, delay);
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
connect();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// fetch version
|
|
||||||
const fetchVersion = async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/version");
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
const data: VersionInfo = await response.json();
|
|
||||||
setVersionInfo(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (connectionStatus === "connected") {
|
|
||||||
fetchVersion();
|
|
||||||
}
|
|
||||||
}, [connectionStatus]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (autoStartAPIEvents) {
|
|
||||||
enableAPIEvents(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
enableAPIEvents(false);
|
|
||||||
};
|
|
||||||
}, [enableAPIEvents, autoStartAPIEvents]);
|
|
||||||
|
|
||||||
const listModels = useCallback(async (): Promise<Model[]> => {
|
|
||||||
try {
|
|
||||||
const response = await fetch("/api/models/");
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP error! status: ${response.status}`);
|
|
||||||
}
|
|
||||||
const data = await response.json();
|
|
||||||
return data || [];
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to fetch models:", error);
|
|
||||||
return []; // Return empty array as fallback
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const unloadAllModels = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/models/unload`, {
|
|
||||||
method: "POST",
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to unload models: ${response.status}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to unload models:", error);
|
|
||||||
throw error; // Re-throw to let calling code handle it
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const unloadSingleModel = useCallback(async (model: string) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/models/unload/${model}`, {
|
|
||||||
method: "POST",
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to unload model: ${response.status}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to unload model", model, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const loadModel = useCallback(async (model: string) => {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/upstream/${model}/`, {
|
|
||||||
method: "GET",
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Failed to load model: ${response.status}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to load model:", error);
|
|
||||||
throw error; // Re-throw to let calling code handle it
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const value = useMemo(
|
|
||||||
() => ({
|
|
||||||
models,
|
|
||||||
listModels,
|
|
||||||
unloadAllModels,
|
|
||||||
unloadSingleModel,
|
|
||||||
loadModel,
|
|
||||||
enableAPIEvents,
|
|
||||||
proxyLogs,
|
|
||||||
upstreamLogs,
|
|
||||||
metrics,
|
|
||||||
connectionStatus,
|
|
||||||
versionInfo,
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
models,
|
|
||||||
listModels,
|
|
||||||
unloadAllModels,
|
|
||||||
unloadSingleModel,
|
|
||||||
loadModel,
|
|
||||||
enableAPIEvents,
|
|
||||||
proxyLogs,
|
|
||||||
upstreamLogs,
|
|
||||||
metrics,
|
|
||||||
connectionStatus,
|
|
||||||
versionInfo,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
return <APIContext.Provider value={value}>{children}</APIContext.Provider>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAPI() {
|
|
||||||
const context = useContext(APIContext);
|
|
||||||
if (context === undefined) {
|
|
||||||
throw new Error("useAPI must be used within an APIProvider");
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
@@ -1,97 +0,0 @@
|
|||||||
import { createContext, useContext, useEffect, type ReactNode, useMemo, useState } from "react";
|
|
||||||
import { usePersistentState } from "../hooks/usePersistentState";
|
|
||||||
import type { ConnectionState } from "../lib/types";
|
|
||||||
|
|
||||||
type ScreenWidth = "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
|
|
||||||
type ThemeContextType = {
|
|
||||||
isDarkMode: boolean;
|
|
||||||
screenWidth: ScreenWidth;
|
|
||||||
isNarrow: boolean;
|
|
||||||
toggleTheme: () => void;
|
|
||||||
|
|
||||||
// for managing the window title and connection state information
|
|
||||||
appTitle: string;
|
|
||||||
setAppTitle: (title: string) => void;
|
|
||||||
setConnectionState: (state: ConnectionState) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
|
||||||
|
|
||||||
type ThemeProviderProps = {
|
|
||||||
children: ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function ThemeProvider({ children }: ThemeProviderProps) {
|
|
||||||
const [appTitle, setAppTitle] = usePersistentState("app-title", "llama-swap");
|
|
||||||
const [connectionState, setConnectionState] = useState<ConnectionState>("disconnected");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the document.title with informative information
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
|
||||||
const connectionIcon = connectionState === "connecting" ? "🟡" : connectionState === "connected" ? "🟢" : "🔴";
|
|
||||||
document.title = connectionIcon + " " + appTitle; // Set initial title
|
|
||||||
}, [appTitle, connectionState]);
|
|
||||||
|
|
||||||
const [isDarkMode, setIsDarkMode] = usePersistentState<boolean>("theme", false);
|
|
||||||
const [screenWidth, setScreenWidth] = useState<ScreenWidth>("md"); // Default to md
|
|
||||||
|
|
||||||
// matches tailwind classes
|
|
||||||
// https://tailwindcss.com/docs/responsive-design
|
|
||||||
useEffect(() => {
|
|
||||||
const checkInnerWidth = () => {
|
|
||||||
const innerWidth = window.innerWidth;
|
|
||||||
if (innerWidth < 640) {
|
|
||||||
setScreenWidth("xs");
|
|
||||||
} else if (innerWidth < 768) {
|
|
||||||
setScreenWidth("sm");
|
|
||||||
} else if (innerWidth < 1024) {
|
|
||||||
setScreenWidth("md");
|
|
||||||
} else if (innerWidth < 1280) {
|
|
||||||
setScreenWidth("lg");
|
|
||||||
} else if (innerWidth < 1536) {
|
|
||||||
setScreenWidth("xl");
|
|
||||||
} else {
|
|
||||||
setScreenWidth("2xl");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
checkInnerWidth();
|
|
||||||
window.addEventListener("resize", checkInnerWidth);
|
|
||||||
|
|
||||||
return () => window.removeEventListener("resize", checkInnerWidth);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
document.documentElement.setAttribute("data-theme", isDarkMode ? "dark" : "light");
|
|
||||||
}, [isDarkMode]);
|
|
||||||
|
|
||||||
const toggleTheme = () => setIsDarkMode((prev) => !prev);
|
|
||||||
const isNarrow = useMemo(() => {
|
|
||||||
return screenWidth === "xs" || screenWidth === "sm" || screenWidth === "md";
|
|
||||||
}, [screenWidth]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemeContext.Provider
|
|
||||||
value={{
|
|
||||||
isDarkMode,
|
|
||||||
toggleTheme,
|
|
||||||
screenWidth,
|
|
||||||
isNarrow,
|
|
||||||
appTitle,
|
|
||||||
setAppTitle,
|
|
||||||
setConnectionState,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</ThemeContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useTheme(): ThemeContextType {
|
|
||||||
const context = useContext(ThemeContext);
|
|
||||||
if (context === undefined) {
|
|
||||||
throw new Error("useTheme must be used within a ThemeProvider");
|
|
||||||
}
|
|
||||||
return context;
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
|
||||||
|
|
||||||
export function usePersistentState<T>(key: string, initialValue: T): [T, (value: T | ((prevState: T) => T)) => void] {
|
|
||||||
const [state, setState] = useState<T>(() => {
|
|
||||||
if (typeof window === "undefined") return initialValue;
|
|
||||||
try {
|
|
||||||
const saved = localStorage.getItem(key);
|
|
||||||
return saved !== null ? JSON.parse(saved) : initialValue;
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Error parsing stored value for ${key}`, e);
|
|
||||||
return initialValue;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const setPersistentState = useCallback(
|
|
||||||
(value: T | ((prevState: T) => T)) => {
|
|
||||||
setState((prev) => {
|
|
||||||
const nextValue = typeof value === "function" ? (value as (prevState: T) => T)(prev) : value;
|
|
||||||
try {
|
|
||||||
localStorage.setItem(key, JSON.stringify(nextValue));
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Error saving value for ${key}`, e);
|
|
||||||
}
|
|
||||||
return nextValue;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[key]
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(key, JSON.stringify(state));
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`Error saving value for ${key}`, e);
|
|
||||||
}
|
|
||||||
}, [key, state]);
|
|
||||||
|
|
||||||
return [state, setPersistentState];
|
|
||||||
}
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
@import "tailwindcss";
|
|
||||||
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
|
|
||||||
|
|
||||||
@theme {
|
|
||||||
--color-background: rgba(252, 252, 249, 1);
|
|
||||||
--color-surface: rgba(255, 255, 253, 1);
|
|
||||||
|
|
||||||
/* text colors */
|
|
||||||
--color-txtmain: rgba(19, 52, 59, 1);
|
|
||||||
--color-txtsecondary: rgba(98, 108, 113, 1);
|
|
||||||
--color-navlink-active: rgba(245, 245, 245, 1);
|
|
||||||
|
|
||||||
--color-primary: rgba(50, 184, 198, 1);
|
|
||||||
|
|
||||||
--color-primary-hover: rgba(29, 116, 128, 1);
|
|
||||||
--color-primary-active: rgba(26, 104, 115, 1);
|
|
||||||
--color-secondary: rgba(94, 82, 64, 0.12);
|
|
||||||
--color-secondary-hover: rgba(94, 82, 64, 0.2);
|
|
||||||
--color-secondary-active: rgba(94, 82, 64, 0.25);
|
|
||||||
--color-border: rgba(94, 82, 64, 0.3);
|
|
||||||
--color-btn-primary-text: rgba(252, 252, 249, 1);
|
|
||||||
--color-card-border: rgba(94, 82, 64, 0.12);
|
|
||||||
--color-card-border-inner: rgba(94, 82, 64, 0.12);
|
|
||||||
--color-error: rgba(192, 21, 47, 1);
|
|
||||||
--color-success: rgba(33, 128, 141, 1);
|
|
||||||
--color-warning: rgb(244, 155, 0);
|
|
||||||
--color-info: rgba(98, 108, 113, 1);
|
|
||||||
--color-focus-ring: rgba(33, 128, 141, 0.4);
|
|
||||||
--color-select-caret: rgba(19, 52, 59, 0.8);
|
|
||||||
--color-btn-border: rgba(94, 82, 64, 0.7);
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer theme {
|
|
||||||
/* over ride theme for dark mode */
|
|
||||||
[data-theme="dark"] {
|
|
||||||
--color-background: rgba(31, 33, 33, 1);
|
|
||||||
--color-surface: rgba(38, 40, 40, 1);
|
|
||||||
/* text colors */
|
|
||||||
--color-txtmain: rgba(245, 245, 245, 1);
|
|
||||||
--color-txtsecondary: rgba(167, 169, 169, 0.7);
|
|
||||||
|
|
||||||
--color-navlink-active: rgba(245, 245, 245, 1);
|
|
||||||
|
|
||||||
--color-primary: rgba(33, 128, 141, 1);
|
|
||||||
--color-primary-hover: rgba(45, 166, 178, 1);
|
|
||||||
--color-primary-active: rgba(41, 150, 161, 1);
|
|
||||||
--color-secondary: rgba(119, 124, 124, 0.15);
|
|
||||||
--color-secondary-hover: rgba(119, 124, 124, 0.25);
|
|
||||||
--color-secondary-active: rgba(119, 124, 124, 0.3);
|
|
||||||
--color-border: rgba(119, 124, 124, 0.3);
|
|
||||||
--color-error: rgba(255, 84, 89, 1);
|
|
||||||
--color-success: rgba(50, 184, 198, 1);
|
|
||||||
--color-warning: rgb(244, 155, 0);
|
|
||||||
--color-info: rgba(167, 169, 169, 1);
|
|
||||||
--color-focus-ring: rgba(50, 184, 198, 0.4);
|
|
||||||
--color-btn-primary-text: rgba(19, 52, 59, 1);
|
|
||||||
--color-card-border: rgba(119, 124, 124, 0.2);
|
|
||||||
--color-card-border-inner: rgba(119, 124, 124, 0.15);
|
|
||||||
--shadow-inset-sm: inset 0 1px 0 rgba(255, 255, 255, 0.1), inset 0 -1px 0 rgba(0, 0, 0, 0.15);
|
|
||||||
--button-border-secondary: rgba(119, 124, 124, 0.2);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
|
||||||
body {
|
|
||||||
/* example of how colors using theme colors*/
|
|
||||||
@apply bg-background text-txtmain;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
@apply text-4xl text-txtmain font-bold pb-4;
|
|
||||||
}
|
|
||||||
h2 {
|
|
||||||
@apply text-3xl text-txtmain font-bold pb-4;
|
|
||||||
}
|
|
||||||
h3 {
|
|
||||||
@apply text-2xl text-txtmain font-bold pb-4;
|
|
||||||
}
|
|
||||||
h4 {
|
|
||||||
@apply text-xl text-txtmain font-bold pb-4;
|
|
||||||
}
|
|
||||||
h5 {
|
|
||||||
@apply text-lg text-txtmain font-bold pb-4;
|
|
||||||
}
|
|
||||||
h6 {
|
|
||||||
@apply text-base text-txtmain font-bold pb-4;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* define CSS classes here for specific types of components */
|
|
||||||
@layer components {
|
|
||||||
.container {
|
|
||||||
@apply px-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tables */
|
|
||||||
table th {
|
|
||||||
@apply p-2 font-semibold;
|
|
||||||
}
|
|
||||||
table td {
|
|
||||||
@apply p-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Navigation Header */
|
|
||||||
|
|
||||||
.navlink {
|
|
||||||
@apply text-txtsecondary hover:bg-secondary hover:text-txtmain rounded-lg p-2;
|
|
||||||
}
|
|
||||||
.navlink.active {
|
|
||||||
@apply bg-primary text-navlink-active;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Card component */
|
|
||||||
.card {
|
|
||||||
@apply bg-surface rounded-lg border border-card-border shadow-sm overflow-hidden p-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card:hover {
|
|
||||||
@apply shadow-md;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card__body {
|
|
||||||
@apply p-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card__header,
|
|
||||||
.card__footer {
|
|
||||||
@apply p-4 border-b border-card-border-inner;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Status Badges */
|
|
||||||
.status {
|
|
||||||
@apply inline-block px-2 py-1 text-xs font-medium rounded-lg;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status--ready {
|
|
||||||
@apply bg-success/10 text-success;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status--starting,
|
|
||||||
.status--stopping {
|
|
||||||
@apply bg-warning/10 text-warning;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status--stopped {
|
|
||||||
@apply bg-error/10 text-error;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Buttons */
|
|
||||||
.btn {
|
|
||||||
@apply bg-surface py-2 px-4 text-sm rounded-md border transition-colors duration-200 border-btn-border;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:hover {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn--sm {
|
|
||||||
@apply px-2 py-0.5 text-xs;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:disabled {
|
|
||||||
@apply opacity-50 cursor-not-allowed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@layer utilities {
|
|
||||||
.ml-2 {
|
|
||||||
margin-left: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.my-8 {
|
|
||||||
margin-top: 2rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export type ConnectionState = "connected" | "connecting" | "disconnected";
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { StrictMode } from "react";
|
|
||||||
import { createRoot } from "react-dom/client";
|
|
||||||
import "./index.css";
|
|
||||||
import App from "./App.tsx";
|
|
||||||
import { ThemeProvider } from "./contexts/ThemeProvider";
|
|
||||||
import { APIProvider } from "./contexts/APIProvider";
|
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
|
||||||
<StrictMode>
|
|
||||||
<ThemeProvider>
|
|
||||||
<APIProvider>
|
|
||||||
<App />
|
|
||||||
</APIProvider>
|
|
||||||
</ThemeProvider>
|
|
||||||
</StrictMode>
|
|
||||||
);
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import { useAPI } from "../contexts/APIProvider";
|
|
||||||
|
|
||||||
const formatSpeed = (speed: number): string => {
|
|
||||||
return speed < 0 ? "unknown" : speed.toFixed(2) + " t/s";
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDuration = (ms: number): string => {
|
|
||||||
return (ms / 1000).toFixed(2) + "s";
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatRelativeTime = (timestamp: string): string => {
|
|
||||||
const now = new Date();
|
|
||||||
const date = new Date(timestamp);
|
|
||||||
const diffInSeconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
|
||||||
|
|
||||||
// Handle future dates by returning "just now"
|
|
||||||
if (diffInSeconds < 5) {
|
|
||||||
return "now";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (diffInSeconds < 60) {
|
|
||||||
return `${diffInSeconds}s ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const diffInMinutes = Math.floor(diffInSeconds / 60);
|
|
||||||
if (diffInMinutes < 60) {
|
|
||||||
return `${diffInMinutes}m ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const diffInHours = Math.floor(diffInMinutes / 60);
|
|
||||||
if (diffInHours < 24) {
|
|
||||||
return `${diffInHours}h ago`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return "a while ago";
|
|
||||||
};
|
|
||||||
|
|
||||||
const ActivityPage = () => {
|
|
||||||
const { metrics } = useAPI();
|
|
||||||
const sortedMetrics = useMemo(() => {
|
|
||||||
return [...metrics].sort((a, b) => b.id - a.id);
|
|
||||||
}, [metrics]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-2">
|
|
||||||
<h1 className="text-2xl font-bold">Activity</h1>
|
|
||||||
|
|
||||||
{metrics.length === 0 && (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<p className="text-gray-600">No metrics data available</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{metrics.length > 0 && (
|
|
||||||
<div className="card overflow-auto">
|
|
||||||
<table className="min-w-full divide-y">
|
|
||||||
<thead className="border-gray-200 dark:border-white/10">
|
|
||||||
<tr className="text-left text-xs uppercase tracking-wider">
|
|
||||||
<th className="px-6 py-3">ID</th>
|
|
||||||
<th className="px-6 py-3">Time</th>
|
|
||||||
<th className="px-6 py-3">Model</th>
|
|
||||||
<th className="px-6 py-3">
|
|
||||||
Cached <Tooltip content="prompt tokens from cache" />
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3">
|
|
||||||
Prompt <Tooltip content="new prompt tokens processed" />
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3">Generated</th>
|
|
||||||
<th className="px-6 py-3">Prompt Processing</th>
|
|
||||||
<th className="px-6 py-3">Generation Speed</th>
|
|
||||||
<th className="px-6 py-3">Duration</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y">
|
|
||||||
{sortedMetrics.map((metric) => (
|
|
||||||
<tr key={metric.id} className="whitespace-nowrap text-sm border-gray-200 dark:border-white/10">
|
|
||||||
<td className="px-4 py-4">{metric.id + 1 /* un-zero index */}</td>
|
|
||||||
<td className="px-6 py-4">{formatRelativeTime(metric.timestamp)}</td>
|
|
||||||
<td className="px-6 py-4">{metric.model}</td>
|
|
||||||
<td className="px-6 py-4">{metric.cache_tokens > 0 ? metric.cache_tokens.toLocaleString() : "-"}</td>
|
|
||||||
<td className="px-6 py-4">{metric.input_tokens.toLocaleString()}</td>
|
|
||||||
<td className="px-6 py-4">{metric.output_tokens.toLocaleString()}</td>
|
|
||||||
<td className="px-6 py-4">{formatSpeed(metric.prompt_per_second)}</td>
|
|
||||||
<td className="px-6 py-4">{formatSpeed(metric.tokens_per_second)}</td>
|
|
||||||
<td className="px-6 py-4">{formatDuration(metric.duration_ms)}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface TooltipProps {
|
|
||||||
content: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Tooltip: React.FC<TooltipProps> = ({ content }) => {
|
|
||||||
return (
|
|
||||||
<div className="relative group inline-block">
|
|
||||||
ⓘ
|
|
||||||
<div
|
|
||||||
className="absolute top-full left-1/2 transform -translate-x-1/2 mt-2
|
|
||||||
px-3 py-2 bg-gray-900 text-white text-sm rounded-md
|
|
||||||
opacity-0 group-hover:opacity-100 transition-opacity
|
|
||||||
duration-200 pointer-events-none whitespace-nowrap z-50 normal-case"
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
<div
|
|
||||||
className="absolute bottom-full left-1/2 transform -translate-x-1/2
|
|
||||||
border-4 border-transparent border-b-gray-900"
|
|
||||||
></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ActivityPage;
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
|
|
||||||
import { useAPI } from "../contexts/APIProvider";
|
|
||||||
import { usePersistentState } from "../hooks/usePersistentState";
|
|
||||||
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
|
|
||||||
import {
|
|
||||||
RiTextWrap,
|
|
||||||
RiAlignJustify,
|
|
||||||
RiFontSize,
|
|
||||||
RiMenuSearchLine,
|
|
||||||
RiMenuSearchFill,
|
|
||||||
RiCloseCircleFill,
|
|
||||||
} from "react-icons/ri";
|
|
||||||
import { useTheme } from "../contexts/ThemeProvider";
|
|
||||||
|
|
||||||
const LogViewer = () => {
|
|
||||||
const { proxyLogs, upstreamLogs } = useAPI();
|
|
||||||
const { screenWidth } = useTheme();
|
|
||||||
const direction = screenWidth === "xs" || screenWidth === "sm" ? "vertical" : "horizontal";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PanelGroup direction={direction} className="gap-2" autoSaveId="logviewer-panel-group">
|
|
||||||
<Panel id="proxy" defaultSize={50} minSize={5} maxSize={100} collapsible={true}>
|
|
||||||
<LogPanel id="proxy" title="Proxy Logs" logData={proxyLogs} />
|
|
||||||
</Panel>
|
|
||||||
<PanelResizeHandle
|
|
||||||
className={
|
|
||||||
direction === "horizontal"
|
|
||||||
? "w-2 h-full bg-primary hover:bg-success transition-colors rounded"
|
|
||||||
: "w-full h-2 bg-primary hover:bg-success transition-colors rounded"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Panel id="upstream" defaultSize={50} minSize={5} maxSize={100} collapsible={true}>
|
|
||||||
<LogPanel id="upstream" title="Upstream Logs" logData={upstreamLogs} />
|
|
||||||
</Panel>
|
|
||||||
</PanelGroup>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface LogPanelProps {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
logData: string;
|
|
||||||
}
|
|
||||||
export const LogPanel = ({ id, title, logData }: LogPanelProps) => {
|
|
||||||
const [filterRegex, setFilterRegex] = useState("");
|
|
||||||
const [fontSize, setFontSize] = usePersistentState<"xxs" | "xs" | "small" | "normal">(
|
|
||||||
`logPanel-${id}-fontSize`,
|
|
||||||
"normal"
|
|
||||||
);
|
|
||||||
const [wrapText, setTextWrap] = usePersistentState(`logPanel-${id}-wrapText`, false);
|
|
||||||
const [showFilter, setShowFilter] = usePersistentState(`logPanel-${id}-showFilter`, false);
|
|
||||||
|
|
||||||
const textWrapClass = useMemo(() => {
|
|
||||||
return wrapText ? "whitespace-pre-wrap" : "whitespace-pre";
|
|
||||||
}, [wrapText]);
|
|
||||||
|
|
||||||
const toggleFontSize = useCallback(() => {
|
|
||||||
setFontSize((prev) => {
|
|
||||||
switch (prev) {
|
|
||||||
case "xxs":
|
|
||||||
return "xs";
|
|
||||||
case "xs":
|
|
||||||
return "small";
|
|
||||||
case "small":
|
|
||||||
return "normal";
|
|
||||||
case "normal":
|
|
||||||
return "xxs";
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleWrapText = useCallback(() => {
|
|
||||||
setTextWrap((prev) => !prev);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const toggleFilter = useCallback(() => {
|
|
||||||
if (showFilter) {
|
|
||||||
setShowFilter(false);
|
|
||||||
setFilterRegex(""); // Clear filter when closing
|
|
||||||
} else {
|
|
||||||
setShowFilter(true);
|
|
||||||
}
|
|
||||||
}, [filterRegex, setFilterRegex, showFilter]);
|
|
||||||
|
|
||||||
const fontSizeClass = useMemo(() => {
|
|
||||||
switch (fontSize) {
|
|
||||||
case "xxs":
|
|
||||||
return "text-[0.5rem]"; // 0.5rem (8px)
|
|
||||||
case "xs":
|
|
||||||
return "text-[0.75rem]"; // 0.75rem (12px)
|
|
||||||
case "small":
|
|
||||||
return "text-[0.875rem]"; // 0.875rem (14px)
|
|
||||||
case "normal":
|
|
||||||
return "text-base"; // 1rem (16px)
|
|
||||||
}
|
|
||||||
}, [fontSize]);
|
|
||||||
|
|
||||||
const filteredLogs = useMemo(() => {
|
|
||||||
if (!filterRegex) return logData;
|
|
||||||
try {
|
|
||||||
const regex = new RegExp(filterRegex, "i");
|
|
||||||
const lines = logData.split("\n");
|
|
||||||
const filtered = lines.filter((line) => regex.test(line));
|
|
||||||
return filtered.join("\n");
|
|
||||||
} catch (e) {
|
|
||||||
return logData; // Return unfiltered if regex is invalid
|
|
||||||
}
|
|
||||||
}, [logData, filterRegex]);
|
|
||||||
|
|
||||||
// auto scroll to bottom
|
|
||||||
const preTagRef = useRef<HTMLPreElement>(null);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!preTagRef.current) return;
|
|
||||||
preTagRef.current.scrollTop = preTagRef.current.scrollHeight;
|
|
||||||
}, [filteredLogs]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="rounded-lg overflow-hidden flex flex-col bg-gray-950/5 dark:bg-white/10 h-full p-1">
|
|
||||||
<div className="p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h3 className="m-0 text-lg p-0">{title}</h3>
|
|
||||||
|
|
||||||
<div className="flex gap-2 items-center">
|
|
||||||
<button className="btn border-0" onClick={toggleFontSize}>
|
|
||||||
<RiFontSize />
|
|
||||||
</button>
|
|
||||||
<button className="btn border-0" onClick={toggleWrapText}>
|
|
||||||
{wrapText ? <RiTextWrap /> : <RiAlignJustify />}
|
|
||||||
</button>
|
|
||||||
<button className="btn border-0" onClick={toggleFilter}>
|
|
||||||
{showFilter ? <RiMenuSearchFill /> : <RiMenuSearchLine />}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filtering Options - Full width on mobile, normal on desktop */}
|
|
||||||
{showFilter && (
|
|
||||||
<div className="mt-2 w-full">
|
|
||||||
<div className="flex gap-2 items-center w-full">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="w-full text-sm border border-gray-950/10 dark:border-white/5 p-2 rounded outline-none"
|
|
||||||
placeholder="Filter logs..."
|
|
||||||
value={filterRegex}
|
|
||||||
onChange={(e) => setFilterRegex(e.target.value)}
|
|
||||||
/>
|
|
||||||
<button className="pl-2" onClick={() => setFilterRegex("")}>
|
|
||||||
<RiCloseCircleFill size="24" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="rounded-lg bg-background font-mono text-sm flex-1 overflow-hidden">
|
|
||||||
<pre ref={preTagRef} className={`${textWrapClass} ${fontSizeClass} h-full overflow-auto p-4`}>
|
|
||||||
{filteredLogs}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
export default LogViewer;
|
|
||||||
@@ -1,527 +0,0 @@
|
|||||||
import { useState, useCallback, useMemo } from "react";
|
|
||||||
import { useAPI } from "../contexts/APIProvider";
|
|
||||||
import { LogPanel } from "./LogViewer";
|
|
||||||
import { usePersistentState } from "../hooks/usePersistentState";
|
|
||||||
import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels";
|
|
||||||
import { useTheme } from "../contexts/ThemeProvider";
|
|
||||||
import { RiEyeFill, RiEyeOffFill, RiSwapBoxFill, RiEjectLine, RiMenuFill } from "react-icons/ri";
|
|
||||||
|
|
||||||
export default function ModelsPage() {
|
|
||||||
const { isNarrow } = useTheme();
|
|
||||||
const direction = isNarrow ? "vertical" : "horizontal";
|
|
||||||
const { upstreamLogs } = useAPI();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PanelGroup direction={direction} className="gap-2" autoSaveId={"models-panel-group"}>
|
|
||||||
<Panel id="models" defaultSize={50} minSize={isNarrow ? 0 : 25} maxSize={100} collapsible={isNarrow}>
|
|
||||||
<ModelsPanel />
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<PanelResizeHandle
|
|
||||||
className={
|
|
||||||
direction === "horizontal"
|
|
||||||
? "w-2 h-full bg-primary hover:bg-success transition-colors rounded"
|
|
||||||
: "w-full h-2 bg-primary hover:bg-success transition-colors rounded"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Panel collapsible={true} defaultSize={50} minSize={0}>
|
|
||||||
<div className="flex flex-col h-full space-y-4">
|
|
||||||
{direction === "horizontal" && <StatsPanel />}
|
|
||||||
<div className="flex-1 min-h-0">
|
|
||||||
<LogPanel id="modelsupstream" title="Upstream Logs" logData={upstreamLogs} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Panel>
|
|
||||||
</PanelGroup>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ModelsPanel() {
|
|
||||||
const { models, loadModel, unloadAllModels, unloadSingleModel } = useAPI();
|
|
||||||
const { isNarrow } = useTheme();
|
|
||||||
const [isUnloading, setIsUnloading] = useState(false);
|
|
||||||
const [showUnlisted, setShowUnlisted] = usePersistentState("showUnlisted", true);
|
|
||||||
const [showIdorName, setShowIdorName] = usePersistentState<"id" | "name">("showIdorName", "id"); // true = show ID, false = show name
|
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
|
||||||
|
|
||||||
const { regularModels, peerModelsByPeerId } = useMemo(() => {
|
|
||||||
const filtered = models.filter((model) => showUnlisted || !model.unlisted);
|
|
||||||
const peerModels = filtered.filter((m) => m.peerID);
|
|
||||||
|
|
||||||
// Group peer models by peerID
|
|
||||||
const grouped = peerModels.reduce((acc, model) => {
|
|
||||||
const peerId = model.peerID || "unknown";
|
|
||||||
if (!acc[peerId]) {
|
|
||||||
acc[peerId] = [];
|
|
||||||
}
|
|
||||||
acc[peerId].push(model);
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, typeof peerModels>);
|
|
||||||
|
|
||||||
return {
|
|
||||||
regularModels: filtered.filter((m) => !m.peerID),
|
|
||||||
peerModelsByPeerId: grouped,
|
|
||||||
};
|
|
||||||
}, [models, showUnlisted]);
|
|
||||||
|
|
||||||
const handleUnloadAllModels = useCallback(async () => {
|
|
||||||
setIsUnloading(true);
|
|
||||||
try {
|
|
||||||
await unloadAllModels();
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
} finally {
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsUnloading(false);
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
}, [unloadAllModels]);
|
|
||||||
|
|
||||||
const toggleIdorName = useCallback(() => {
|
|
||||||
setShowIdorName((prev) => (prev === "name" ? "id" : "name"));
|
|
||||||
}, [showIdorName]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="card h-full flex flex-col">
|
|
||||||
<div className="shrink-0">
|
|
||||||
<div className="flex justify-between items-baseline">
|
|
||||||
<h2 className={isNarrow ? "text-xl" : ""}>Models</h2>
|
|
||||||
{isNarrow && (
|
|
||||||
<div className="relative">
|
|
||||||
<button className="btn text-base flex items-center gap-2 py-1" onClick={() => setMenuOpen(!menuOpen)}>
|
|
||||||
<RiMenuFill size="20" />
|
|
||||||
</button>
|
|
||||||
{menuOpen && (
|
|
||||||
<div className="absolute right-0 mt-2 w-48 bg-surface border border-gray-200 dark:border-white/10 rounded shadow-lg z-20">
|
|
||||||
<button
|
|
||||||
className="w-full text-left px-4 py-2 hover:bg-secondary-hover flex items-center gap-2"
|
|
||||||
onClick={() => {
|
|
||||||
toggleIdorName();
|
|
||||||
setMenuOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<RiSwapBoxFill size="20" /> {showIdorName === "id" ? "Show Name" : "Show ID"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="w-full text-left px-4 py-2 hover:bg-secondary-hover flex items-center gap-2"
|
|
||||||
onClick={() => {
|
|
||||||
setShowUnlisted(!showUnlisted);
|
|
||||||
setMenuOpen(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{showUnlisted ? <RiEyeOffFill size="20" /> : <RiEyeFill size="20" />}{" "}
|
|
||||||
{showUnlisted ? "Hide Unlisted" : "Show Unlisted"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="w-full text-left px-4 py-2 hover:bg-secondary-hover flex items-center gap-2"
|
|
||||||
onClick={() => {
|
|
||||||
handleUnloadAllModels();
|
|
||||||
setMenuOpen(false);
|
|
||||||
}}
|
|
||||||
disabled={isUnloading}
|
|
||||||
>
|
|
||||||
<RiEjectLine size="24" /> {isUnloading ? "Unloading..." : "Unload All"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{!isNarrow && (
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
className="btn text-base flex items-center gap-2"
|
|
||||||
onClick={toggleIdorName}
|
|
||||||
style={{ lineHeight: "1.2" }}
|
|
||||||
>
|
|
||||||
<RiSwapBoxFill size="20" /> {showIdorName === "id" ? "ID" : "Name"}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
className="btn text-base flex items-center gap-2"
|
|
||||||
onClick={() => setShowUnlisted(!showUnlisted)}
|
|
||||||
style={{ lineHeight: "1.2" }}
|
|
||||||
>
|
|
||||||
{showUnlisted ? <RiEyeFill size="20" /> : <RiEyeOffFill size="20" />} unlisted
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
className="btn text-base flex items-center gap-2"
|
|
||||||
onClick={handleUnloadAllModels}
|
|
||||||
disabled={isUnloading}
|
|
||||||
>
|
|
||||||
<RiEjectLine size="24" /> {isUnloading ? "Unloading..." : "Unload All"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead className="sticky top-0 bg-card z-10">
|
|
||||||
<tr className="text-left border-b border-gray-200 dark:border-white/10 bg-surface">
|
|
||||||
<th>{showIdorName === "id" ? "Model ID" : "Name"}</th>
|
|
||||||
<th></th>
|
|
||||||
<th>State</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{regularModels.map((model) => (
|
|
||||||
<tr key={model.id} className="border-b hover:bg-secondary-hover border-gray-200">
|
|
||||||
<td className={`${model.unlisted ? "text-txtsecondary" : ""}`}>
|
|
||||||
<a href={`/upstream/${model.id}/`} className="font-semibold" target="_blank">
|
|
||||||
{showIdorName === "id" ? model.id : model.name !== "" ? model.name : model.id}
|
|
||||||
</a>
|
|
||||||
|
|
||||||
{!!model.description && (
|
|
||||||
<p className={model.unlisted ? "text-opacity-70" : ""}>
|
|
||||||
<em>{model.description}</em>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="w-12">
|
|
||||||
{model.state === "stopped" ? (
|
|
||||||
<button className="btn btn--sm" onClick={() => loadModel(model.id)}>
|
|
||||||
Load
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
className="btn btn--sm"
|
|
||||||
onClick={() => unloadSingleModel(model.id)}
|
|
||||||
disabled={model.state !== "ready"}
|
|
||||||
>
|
|
||||||
Unload
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="w-20">
|
|
||||||
<span className={`w-16 text-center status status--${model.state}`}>{model.state}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
|
|
||||||
{Object.keys(peerModelsByPeerId).length > 0 && (
|
|
||||||
<>
|
|
||||||
<h3 className="mt-8 mb-2">Peer Models</h3>
|
|
||||||
{Object.entries(peerModelsByPeerId)
|
|
||||||
.sort(([a], [b]) => a.localeCompare(b))
|
|
||||||
.map(([peerId, models]) => (
|
|
||||||
<div key={peerId} className="mb-4">
|
|
||||||
<table className="w-full">
|
|
||||||
<thead className="sticky top-0 bg-card z-10">
|
|
||||||
<tr className="text-left border-b border-gray-200 dark:border-white/10 bg-surface">
|
|
||||||
<th className="font-semibold">{peerId}</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{models.map((model) => (
|
|
||||||
<tr key={model.id} className="border-b hover:bg-secondary-hover border-gray-200">
|
|
||||||
<td className={`pl-8 ${model.unlisted ? "text-txtsecondary" : ""}`}>
|
|
||||||
<span>{model.id}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HistogramData {
|
|
||||||
bins: number[];
|
|
||||||
min: number;
|
|
||||||
max: number;
|
|
||||||
binSize: number;
|
|
||||||
p99: number;
|
|
||||||
p95: number;
|
|
||||||
p50: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function TokenHistogram({ data }: { data: HistogramData }) {
|
|
||||||
const { bins, min, max, p50, p95, p99 } = data;
|
|
||||||
const maxCount = Math.max(...bins);
|
|
||||||
|
|
||||||
const height = 120;
|
|
||||||
const padding = { top: 10, right: 15, bottom: 25, left: 45 };
|
|
||||||
|
|
||||||
// Use viewBox for responsive sizing
|
|
||||||
const viewBoxWidth = 600;
|
|
||||||
const chartWidth = viewBoxWidth - padding.left - padding.right;
|
|
||||||
const chartHeight = height - padding.top - padding.bottom;
|
|
||||||
|
|
||||||
const barWidth = chartWidth / bins.length;
|
|
||||||
const range = max - min;
|
|
||||||
|
|
||||||
// Calculate x position for a given value
|
|
||||||
const getXPosition = (value: number) => {
|
|
||||||
return padding.left + ((value - min) / range) * chartWidth;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-2 w-full">
|
|
||||||
<svg viewBox={`0 0 ${viewBoxWidth} ${height}`} className="w-full h-auto" preserveAspectRatio="xMidYMid meet">
|
|
||||||
{/* Y-axis */}
|
|
||||||
<line
|
|
||||||
x1={padding.left}
|
|
||||||
y1={padding.top}
|
|
||||||
x2={padding.left}
|
|
||||||
y2={height - padding.bottom}
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1"
|
|
||||||
opacity="0.3"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* X-axis */}
|
|
||||||
<line
|
|
||||||
x1={padding.left}
|
|
||||||
y1={height - padding.bottom}
|
|
||||||
x2={viewBoxWidth - padding.right}
|
|
||||||
y2={height - padding.bottom}
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1"
|
|
||||||
opacity="0.3"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Histogram bars */}
|
|
||||||
{bins.map((count, i) => {
|
|
||||||
const barHeight = maxCount > 0 ? (count / maxCount) * chartHeight : 0;
|
|
||||||
const x = padding.left + i * barWidth;
|
|
||||||
const y = height - padding.bottom - barHeight;
|
|
||||||
const binStart = min + i * data.binSize;
|
|
||||||
const binEnd = binStart + data.binSize;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<g key={i}>
|
|
||||||
<rect
|
|
||||||
x={x}
|
|
||||||
y={y}
|
|
||||||
width={Math.max(barWidth - 1, 1)}
|
|
||||||
height={barHeight}
|
|
||||||
fill="currentColor"
|
|
||||||
opacity="0.6"
|
|
||||||
className="text-blue-500 dark:text-blue-400 hover:opacity-90 transition-opacity cursor-pointer"
|
|
||||||
/>
|
|
||||||
<title>{`${binStart.toFixed(1)} - ${binEnd.toFixed(1)} tokens/sec\nCount: ${count}`}</title>
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Percentile lines */}
|
|
||||||
<line
|
|
||||||
x1={getXPosition(p50)}
|
|
||||||
y1={padding.top}
|
|
||||||
x2={getXPosition(p50)}
|
|
||||||
y2={height - padding.bottom}
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeDasharray="4 2"
|
|
||||||
opacity="0.7"
|
|
||||||
className="text-gray-600 dark:text-gray-400"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<line
|
|
||||||
x1={getXPosition(p95)}
|
|
||||||
y1={padding.top}
|
|
||||||
x2={getXPosition(p95)}
|
|
||||||
y2={height - padding.bottom}
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeDasharray="4 2"
|
|
||||||
opacity="0.7"
|
|
||||||
className="text-orange-500 dark:text-orange-400"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<line
|
|
||||||
x1={getXPosition(p99)}
|
|
||||||
y1={padding.top}
|
|
||||||
x2={getXPosition(p99)}
|
|
||||||
y2={height - padding.bottom}
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeDasharray="4 2"
|
|
||||||
opacity="0.7"
|
|
||||||
className="text-green-500 dark:text-green-400"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* X-axis labels */}
|
|
||||||
<text x={padding.left} y={height - 5} fontSize="10" fill="currentColor" opacity="0.6" textAnchor="start">
|
|
||||||
{min.toFixed(1)}
|
|
||||||
</text>
|
|
||||||
|
|
||||||
<text
|
|
||||||
x={viewBoxWidth - padding.right}
|
|
||||||
y={height - 5}
|
|
||||||
fontSize="10"
|
|
||||||
fill="currentColor"
|
|
||||||
opacity="0.6"
|
|
||||||
textAnchor="end"
|
|
||||||
>
|
|
||||||
{max.toFixed(1)}
|
|
||||||
</text>
|
|
||||||
|
|
||||||
{/* X-axis label */}
|
|
||||||
<text
|
|
||||||
x={padding.left + chartWidth / 2}
|
|
||||||
y={height - 2}
|
|
||||||
fontSize="10"
|
|
||||||
fill="currentColor"
|
|
||||||
opacity="0.6"
|
|
||||||
textAnchor="middle"
|
|
||||||
>
|
|
||||||
Tokens/Second Distribution
|
|
||||||
</text>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatsPanel() {
|
|
||||||
const { metrics } = useAPI();
|
|
||||||
|
|
||||||
const [totalRequests, totalInputTokens, totalOutputTokens, tokenStats, histogramData] = useMemo(() => {
|
|
||||||
const totalRequests = metrics.length;
|
|
||||||
if (totalRequests === 0) {
|
|
||||||
return [0, 0, 0, { p99: 0, p95: 0, p50: 0 }, null];
|
|
||||||
}
|
|
||||||
const totalInputTokens = metrics.reduce((sum, m) => sum + m.input_tokens, 0);
|
|
||||||
const totalOutputTokens = metrics.reduce((sum, m) => sum + m.output_tokens, 0);
|
|
||||||
|
|
||||||
// Calculate token statistics using output_tokens and duration_ms
|
|
||||||
// Filter out metrics with invalid duration or output tokens
|
|
||||||
const validMetrics = metrics.filter((m) => m.duration_ms > 0 && m.output_tokens > 0);
|
|
||||||
if (validMetrics.length === 0) {
|
|
||||||
return [totalRequests, totalInputTokens, totalOutputTokens, { p99: 0, p95: 0, p50: 0 }, null];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate tokens/second for each valid metric
|
|
||||||
const tokensPerSecond = validMetrics.map((m) => m.output_tokens / (m.duration_ms / 1000));
|
|
||||||
|
|
||||||
// Sort for percentile calculation
|
|
||||||
const sortedTokensPerSecond = [...tokensPerSecond].sort((a, b) => a - b);
|
|
||||||
|
|
||||||
// Calculate percentiles - showing speed thresholds where X% of requests are SLOWER (below)
|
|
||||||
// P99: 99% of requests are slower than this speed (99th percentile - fast requests)
|
|
||||||
// P95: 95% of requests are slower than this speed (95th percentile)
|
|
||||||
// P50: 50% of requests are slower than this speed (median)
|
|
||||||
const p99 = sortedTokensPerSecond[Math.floor(sortedTokensPerSecond.length * 0.99)];
|
|
||||||
const p95 = sortedTokensPerSecond[Math.floor(sortedTokensPerSecond.length * 0.95)];
|
|
||||||
const p50 = sortedTokensPerSecond[Math.floor(sortedTokensPerSecond.length * 0.5)];
|
|
||||||
|
|
||||||
// Create histogram data
|
|
||||||
const min = Math.min(...tokensPerSecond);
|
|
||||||
const max = Math.max(...tokensPerSecond);
|
|
||||||
const binCount = Math.min(30, Math.max(10, Math.floor(tokensPerSecond.length / 5))); // Adaptive bin count
|
|
||||||
const binSize = (max - min) / binCount;
|
|
||||||
|
|
||||||
const bins = Array(binCount).fill(0);
|
|
||||||
tokensPerSecond.forEach((value) => {
|
|
||||||
const binIndex = Math.min(Math.floor((value - min) / binSize), binCount - 1);
|
|
||||||
bins[binIndex]++;
|
|
||||||
});
|
|
||||||
|
|
||||||
const histogramData = {
|
|
||||||
bins,
|
|
||||||
min,
|
|
||||||
max,
|
|
||||||
binSize,
|
|
||||||
p99,
|
|
||||||
p95,
|
|
||||||
p50,
|
|
||||||
};
|
|
||||||
|
|
||||||
return [
|
|
||||||
totalRequests,
|
|
||||||
totalInputTokens,
|
|
||||||
totalOutputTokens,
|
|
||||||
{
|
|
||||||
p99: p99.toFixed(2),
|
|
||||||
p95: p95.toFixed(2),
|
|
||||||
p50: p50.toFixed(2),
|
|
||||||
},
|
|
||||||
histogramData,
|
|
||||||
];
|
|
||||||
}, [metrics]);
|
|
||||||
|
|
||||||
const nf = new Intl.NumberFormat();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="card">
|
|
||||||
<div className="rounded-lg overflow-hidden border border-card-border-inner">
|
|
||||||
<table className="min-w-full divide-y divide-card-border-inner">
|
|
||||||
<thead className="bg-secondary">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-txtmain">
|
|
||||||
Requests
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-txtmain border-l border-card-border-inner">
|
|
||||||
Processed
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-txtmain border-l border-card-border-inner">
|
|
||||||
Generated
|
|
||||||
</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-semibold uppercase tracking-wider text-txtmain border-l border-card-border-inner">
|
|
||||||
Token Stats (tokens/sec)
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
|
|
||||||
<tbody className="bg-surface divide-y divide-card-border-inner">
|
|
||||||
<tr className="hover:bg-secondary">
|
|
||||||
<td className="px-4 py-4 text-sm font-semibold text-gray-900 dark:text-white">{totalRequests}</td>
|
|
||||||
|
|
||||||
<td className="px-4 py-4 text-sm text-gray-700 dark:text-gray-300 border-l border-gray-200 dark:border-white/10">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-medium">{nf.format(totalInputTokens)}</span>
|
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">tokens</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className="px-4 py-4 text-sm text-gray-700 dark:text-gray-300 border-l border-gray-200 dark:border-white/10">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm font-medium">{nf.format(totalOutputTokens)}</span>
|
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">tokens</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className="px-4 py-4 border-l border-gray-200 dark:border-white/10">
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="grid grid-cols-3 gap-2 items-center">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">P50</div>
|
|
||||||
<div className="mt-1 inline-block rounded-full bg-gray-100 dark:bg-white/5 px-3 py-1 text-sm font-semibold text-gray-800 dark:text-white">
|
|
||||||
{tokenStats.p50}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">P95</div>
|
|
||||||
<div className="mt-1 inline-block rounded-full bg-gray-100 dark:bg-white/5 px-3 py-1 text-sm font-semibold text-gray-800 dark:text-white">
|
|
||||||
{tokenStats.p95}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">P99</div>
|
|
||||||
<div className="mt-1 inline-block rounded-full bg-gray-100 dark:bg-white/5 px-3 py-1 text-sm font-semibold text-gray-800 dark:text-white">
|
|
||||||
{tokenStats.p99}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{histogramData && <TokenHistogram data={histogramData} />}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/// <reference types="vite/client" />
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
|
||||||
"target": "ES2020",
|
|
||||||
"useDefineForClassFields": true,
|
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
||||||
"module": "ESNext",
|
|
||||||
"skipLibCheck": true,
|
|
||||||
|
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"noEmit": true,
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
|
|
||||||
/* Linting */
|
|
||||||
"strict": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"erasableSyntaxOnly": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"noUncheckedSideEffectImports": true
|
|
||||||
},
|
|
||||||
"include": ["src"]
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"files": [],
|
|
||||||
"references": [
|
|
||||||
{ "path": "./tsconfig.app.json" },
|
|
||||||
{ "path": "./tsconfig.node.json" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
|
||||||
"target": "ES2022",
|
|
||||||
"lib": ["ES2023"],
|
|
||||||
"module": "ESNext",
|
|
||||||
"skipLibCheck": true,
|
|
||||||
|
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"noEmit": true,
|
|
||||||
|
|
||||||
/* Linting */
|
|
||||||
"strict": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"erasableSyntaxOnly": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"noUncheckedSideEffectImports": true
|
|
||||||
},
|
|
||||||
"include": ["vite.config.ts"]
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
import { defineConfig } from "vite";
|
|
||||||
import react from "@vitejs/plugin-react";
|
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
|
||||||
|
|
||||||
// https://vite.dev/config/
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react(), tailwindcss()],
|
|
||||||
base: "/ui/",
|
|
||||||
build: {
|
|
||||||
outDir: "../proxy/ui_dist",
|
|
||||||
assetsDir: "assets",
|
|
||||||
},
|
|
||||||
server: {
|
|
||||||
proxy: {
|
|
||||||
"/api": "http://localhost:8080", // Proxy API calls to Go backend during development
|
|
||||||
"/logs": "http://localhost:8080",
|
|
||||||
"/upstream": "http://localhost:8080",
|
|
||||||
"/unload": "http://localhost:8080",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||