Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4b4ee70154 |
+1
-1
@@ -4,7 +4,7 @@ early_access: false
|
|||||||
reviews:
|
reviews:
|
||||||
profile: "chill"
|
profile: "chill"
|
||||||
request_changes_workflow: false
|
request_changes_workflow: false
|
||||||
high_level_summary: false
|
high_level_summary: true
|
||||||
poem: false
|
poem: false
|
||||||
review_status: true
|
review_status: true
|
||||||
collapse_walkthrough: false
|
collapse_walkthrough: false
|
||||||
|
|||||||
@@ -17,13 +17,6 @@ on:
|
|||||||
- 'docker/build-container.sh'
|
- 'docker/build-container.sh'
|
||||||
- 'docker/*.Containerfile'
|
- 'docker/*.Containerfile'
|
||||||
|
|
||||||
# grant permissions on GITHUB_TOKEN to publish packages
|
|
||||||
# ref: https://docs.github.com/en/packages/managing-github-packages-using-github-actions-workflows/publishing-and-installing-a-package-with-github-actions#publishing-a-package-using-an-action
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
packages: write
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
## Project Description:
|
|
||||||
|
|
||||||
llama-swap is a light weight, transparent proxy server that provides automatic model swapping to llama.cpp's server.
|
|
||||||
|
|
||||||
## Tech stack
|
|
||||||
|
|
||||||
- golang
|
|
||||||
- typescript, vite and svelt5 for UI (located in ui/)
|
|
||||||
|
|
||||||
## Workflow Tasks
|
|
||||||
|
|
||||||
- when summarizing changes only include details that require further action
|
|
||||||
- just say "Done." when there is no further action
|
|
||||||
- use the github CLI `gh` to create pull requests and work with github
|
|
||||||
- Rules for creating pull requests:
|
|
||||||
- keep them short and focused on changes.
|
|
||||||
- never include a test plan
|
|
||||||
- write the summary using the same style rules as commit message
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
- Follow test naming conventions like `TestProxyManager_<test name>`, `TestProcessGroup_<test name>`, etc.
|
|
||||||
- Use `go test -v -run <name pattern for new tests>` to run any new tests you've written.
|
|
||||||
- Use `make test-dev` after running new tests for a quick over all test run. This runs `go test` and `staticcheck`. Fix any static checking errors. Use this only when changes are made to any code under the `proxy/` directory
|
|
||||||
- Use `make test-all` before completing work. This includes long running concurrency tests.
|
|
||||||
|
|
||||||
### Commit message example format:
|
|
||||||
|
|
||||||
```
|
|
||||||
proxy: add new feature
|
|
||||||
|
|
||||||
Add new feature that implements functionality X and Y.
|
|
||||||
|
|
||||||
- key change 1
|
|
||||||
- key change 2
|
|
||||||
- key change 3
|
|
||||||
|
|
||||||
fixes #123
|
|
||||||
```
|
|
||||||
|
|
||||||
## Code Reviews
|
|
||||||
|
|
||||||
- use three levels High, Medium, Low severity
|
|
||||||
- label each discovered issue with a label like H1, M2, L3 respectively
|
|
||||||
- High severity are must fix issues (security, race conditions, critical bugs)
|
|
||||||
- Medium severity are recommended improvements (coding style, missing functionality, inconsistencies)
|
|
||||||
- Low severity are nice to have changes and nits
|
|
||||||
- Include a suggestion with each discovered item
|
|
||||||
- Limit your code review to three items with the highest priority first
|
|
||||||
- Double check your discovered items and recommended remediations
|
|
||||||
@@ -1 +1,49 @@
|
|||||||
@AGENTS.md
|
## Project Description:
|
||||||
|
|
||||||
|
llama-swap is a light weight, transparent proxy server that provides automatic model swapping to llama.cpp's server.
|
||||||
|
|
||||||
|
## Tech stack
|
||||||
|
|
||||||
|
- golang
|
||||||
|
- typescript, vite and react for UI (located in ui/)
|
||||||
|
|
||||||
|
## Workflow Tasks
|
||||||
|
|
||||||
|
- when summarizing changes only include details that require further action
|
||||||
|
- just say "Done." when there is no further action
|
||||||
|
- use `gh` to create PRs and load issues
|
||||||
|
- do include Co-Authored-By or created by when committing changes or creating PRs
|
||||||
|
- keep PR descriptions short and focused on changes.
|
||||||
|
- never include a test plan
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Follow test naming conventions like `TestProxyManager_<test name>`, `TestProcessGroup_<test name>`, etc.
|
||||||
|
- Use `go test -v -run <name pattern for new tests>` to run any new tests you've written.
|
||||||
|
- Use `make test-dev` after running new tests for a quick over all test run. This runs `go test` and `staticcheck`. Fix any static checking errors. Use this only when changes are made to any code under the `proxy/` directory
|
||||||
|
- Use `make test-all` before completing work. This includes long running concurrency tests.
|
||||||
|
|
||||||
|
### Commit message example format:
|
||||||
|
|
||||||
|
```
|
||||||
|
proxy: add new feature
|
||||||
|
|
||||||
|
Add new feature that implements functionality X and Y.
|
||||||
|
|
||||||
|
- key change 1
|
||||||
|
- key change 2
|
||||||
|
- key change 3
|
||||||
|
|
||||||
|
fixes #123
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Reviews
|
||||||
|
|
||||||
|
- use three levels High, Medium, Low severity
|
||||||
|
- label each discovered issue with a label like H1, M2, L3 respectively
|
||||||
|
- High severity are must fix issues (security, race conditions, critical bugs)
|
||||||
|
- Medium severity are recommended improvements (coding style, missing functionality, inconsistencies)
|
||||||
|
- Low severity are nice to have changes and nits
|
||||||
|
- Include a suggestion with each discovered item
|
||||||
|
- Limit your code review to three items with the highest priority first
|
||||||
|
- Double check your discovered items and recommended remediations
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ for CONTAINER_TYPE in non-root root; do
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
log_info "Building $CONTAINER_TYPE $CONTAINER_TAG $LS_VER"
|
log_info "Building $CONTAINER_TYPE $CONTAINER_TAG $LS_VER"
|
||||||
docker build --provenance=false -f llama-swap.Containerfile --build-arg BASE_TAG=${BASE_TAG} --build-arg LS_VER=${LS_VER} --build-arg UID=${USER_UID} \
|
docker build -f llama-swap.Containerfile --build-arg BASE_TAG=${BASE_TAG} --build-arg LS_VER=${LS_VER} --build-arg UID=${USER_UID} \
|
||||||
--build-arg LS_REPO=${LS_REPO} --build-arg GID=${USER_GID} --build-arg USER_HOME=${USER_HOME} -t ${CONTAINER_TAG} -t ${CONTAINER_LATEST} \
|
--build-arg LS_REPO=${LS_REPO} --build-arg GID=${USER_GID} --build-arg USER_HOME=${USER_HOME} -t ${CONTAINER_TAG} -t ${CONTAINER_LATEST} \
|
||||||
--build-arg BASE_IMAGE=${BASE_IMAGE} .
|
--build-arg BASE_IMAGE=${BASE_IMAGE} .
|
||||||
|
|
||||||
@@ -150,7 +150,7 @@ for CONTAINER_TYPE in non-root root; do
|
|||||||
case "$ARCH" in
|
case "$ARCH" in
|
||||||
"musa" | "vulkan")
|
"musa" | "vulkan")
|
||||||
log_info "Adding sd-server to $CONTAINER_TAG"
|
log_info "Adding sd-server to $CONTAINER_TAG"
|
||||||
docker build --provenance=false -f llama-swap-sd.Containerfile \
|
docker build -f llama-swap-sd.Containerfile \
|
||||||
--build-arg BASE=${CONTAINER_TAG} \
|
--build-arg BASE=${CONTAINER_TAG} \
|
||||||
--build-arg SD_IMAGE=${SD_IMAGE} --build-arg SD_TAG=${SD_TAG} \
|
--build-arg SD_IMAGE=${SD_IMAGE} --build-arg SD_TAG=${SD_TAG} \
|
||||||
--build-arg UID=${USER_UID} --build-arg GID=${USER_GID} \
|
--build-arg UID=${USER_UID} --build-arg GID=${USER_GID} \
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ const ConfigFileChangedEventID = 0x03
|
|||||||
const LogDataEventID = 0x04
|
const LogDataEventID = 0x04
|
||||||
const TokenMetricsEventID = 0x05
|
const TokenMetricsEventID = 0x05
|
||||||
const ModelPreloadedEventID = 0x06
|
const ModelPreloadedEventID = 0x06
|
||||||
const InFlightRequestsEventID = 0x07
|
|
||||||
|
|
||||||
type ProcessStateChangeEvent struct {
|
type ProcessStateChangeEvent struct {
|
||||||
ProcessName string
|
ProcessName string
|
||||||
@@ -59,11 +58,3 @@ type ModelPreloadedEvent struct {
|
|||||||
func (e ModelPreloadedEvent) Type() uint32 {
|
func (e ModelPreloadedEvent) Type() uint32 {
|
||||||
return ModelPreloadedEventID
|
return ModelPreloadedEventID
|
||||||
}
|
}
|
||||||
|
|
||||||
type InFlightRequestsEvent struct {
|
|
||||||
Total int
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e InFlightRequestsEvent) Type() uint32 {
|
|
||||||
return InFlightRequestsEventID
|
|
||||||
}
|
|
||||||
|
|||||||
+19
-65
@@ -28,40 +28,6 @@ const (
|
|||||||
|
|
||||||
type proxyCtxKey string
|
type proxyCtxKey string
|
||||||
|
|
||||||
type InflightCounter struct {
|
|
||||||
mu sync.Mutex
|
|
||||||
total int
|
|
||||||
}
|
|
||||||
|
|
||||||
func newInflightCounter() *InflightCounter {
|
|
||||||
return &InflightCounter{}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ic *InflightCounter) Current() int {
|
|
||||||
ic.mu.Lock()
|
|
||||||
total := ic.total
|
|
||||||
ic.mu.Unlock()
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ic *InflightCounter) Increment() int {
|
|
||||||
ic.mu.Lock()
|
|
||||||
ic.total++
|
|
||||||
total := ic.total
|
|
||||||
ic.mu.Unlock()
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
|
|
||||||
func (ic *InflightCounter) Decrement() int {
|
|
||||||
ic.mu.Lock()
|
|
||||||
if ic.total > 0 {
|
|
||||||
ic.total--
|
|
||||||
}
|
|
||||||
total := ic.total
|
|
||||||
ic.mu.Unlock()
|
|
||||||
return total
|
|
||||||
}
|
|
||||||
|
|
||||||
type ProxyManager struct {
|
type ProxyManager struct {
|
||||||
sync.Mutex
|
sync.Mutex
|
||||||
|
|
||||||
@@ -77,8 +43,6 @@ type ProxyManager struct {
|
|||||||
|
|
||||||
processGroups map[string]*ProcessGroup
|
processGroups map[string]*ProcessGroup
|
||||||
|
|
||||||
inFlightCounter *InflightCounter
|
|
||||||
|
|
||||||
// shutdown signaling
|
// shutdown signaling
|
||||||
shutdownCtx context.Context
|
shutdownCtx context.Context
|
||||||
shutdownCancel context.CancelFunc
|
shutdownCancel context.CancelFunc
|
||||||
@@ -191,8 +155,6 @@ func New(proxyConfig config.Config) *ProxyManager {
|
|||||||
|
|
||||||
processGroups: make(map[string]*ProcessGroup),
|
processGroups: make(map[string]*ProcessGroup),
|
||||||
|
|
||||||
inFlightCounter: newInflightCounter(),
|
|
||||||
|
|
||||||
shutdownCtx: shutdownCtx,
|
shutdownCtx: shutdownCtx,
|
||||||
shutdownCancel: shutdownCancel,
|
shutdownCancel: shutdownCancel,
|
||||||
|
|
||||||
@@ -314,37 +276,37 @@ func (pm *ProxyManager) setupGinEngine() {
|
|||||||
|
|
||||||
// Set up routes using the Gin engine
|
// Set up routes using the Gin engine
|
||||||
// Protected routes use pm.apiKeyAuth() middleware
|
// Protected routes use pm.apiKeyAuth() middleware
|
||||||
pm.ginEngine.POST("/v1/chat/completions", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyInferenceHandler)
|
pm.ginEngine.POST("/v1/chat/completions", pm.apiKeyAuth(), pm.proxyInferenceHandler)
|
||||||
pm.ginEngine.POST("/v1/responses", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyInferenceHandler)
|
pm.ginEngine.POST("/v1/responses", pm.apiKeyAuth(), pm.proxyInferenceHandler)
|
||||||
// Support legacy /v1/completions api, see issue #12
|
// Support legacy /v1/completions api, see issue #12
|
||||||
pm.ginEngine.POST("/v1/completions", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyInferenceHandler)
|
pm.ginEngine.POST("/v1/completions", pm.apiKeyAuth(), pm.proxyInferenceHandler)
|
||||||
// Support anthropic /v1/messages (added https://github.com/ggml-org/llama.cpp/pull/17570)
|
// Support anthropic /v1/messages (added https://github.com/ggml-org/llama.cpp/pull/17570)
|
||||||
pm.ginEngine.POST("/v1/messages", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyInferenceHandler)
|
pm.ginEngine.POST("/v1/messages", pm.apiKeyAuth(), pm.proxyInferenceHandler)
|
||||||
// Support anthropic count_tokens API (Also added in the above PR)
|
// Support anthropic count_tokens API (Also added in the above PR)
|
||||||
pm.ginEngine.POST("/v1/messages/count_tokens", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyInferenceHandler)
|
pm.ginEngine.POST("/v1/messages/count_tokens", pm.apiKeyAuth(), pm.proxyInferenceHandler)
|
||||||
|
|
||||||
// Support embeddings and reranking
|
// Support embeddings and reranking
|
||||||
pm.ginEngine.POST("/v1/embeddings", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyInferenceHandler)
|
pm.ginEngine.POST("/v1/embeddings", pm.apiKeyAuth(), pm.proxyInferenceHandler)
|
||||||
|
|
||||||
// llama-server's /reranking endpoint + aliases
|
// llama-server's /reranking endpoint + aliases
|
||||||
pm.ginEngine.POST("/reranking", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyInferenceHandler)
|
pm.ginEngine.POST("/reranking", pm.apiKeyAuth(), pm.proxyInferenceHandler)
|
||||||
pm.ginEngine.POST("/rerank", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyInferenceHandler)
|
pm.ginEngine.POST("/rerank", pm.apiKeyAuth(), pm.proxyInferenceHandler)
|
||||||
pm.ginEngine.POST("/v1/rerank", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyInferenceHandler)
|
pm.ginEngine.POST("/v1/rerank", pm.apiKeyAuth(), pm.proxyInferenceHandler)
|
||||||
pm.ginEngine.POST("/v1/reranking", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyInferenceHandler)
|
pm.ginEngine.POST("/v1/reranking", pm.apiKeyAuth(), pm.proxyInferenceHandler)
|
||||||
|
|
||||||
// llama-server's /infill endpoint for code infilling
|
// llama-server's /infill endpoint for code infilling
|
||||||
pm.ginEngine.POST("/infill", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyInferenceHandler)
|
pm.ginEngine.POST("/infill", pm.apiKeyAuth(), pm.proxyInferenceHandler)
|
||||||
|
|
||||||
// llama-server's /completion endpoint
|
// llama-server's /completion endpoint
|
||||||
pm.ginEngine.POST("/completion", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyInferenceHandler)
|
pm.ginEngine.POST("/completion", pm.apiKeyAuth(), pm.proxyInferenceHandler)
|
||||||
|
|
||||||
// Support audio/speech endpoint
|
// Support audio/speech endpoint
|
||||||
pm.ginEngine.POST("/v1/audio/speech", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyInferenceHandler)
|
pm.ginEngine.POST("/v1/audio/speech", pm.apiKeyAuth(), pm.proxyInferenceHandler)
|
||||||
pm.ginEngine.POST("/v1/audio/voices", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyInferenceHandler)
|
pm.ginEngine.POST("/v1/audio/voices", pm.apiKeyAuth(), pm.proxyInferenceHandler)
|
||||||
pm.ginEngine.GET("/v1/audio/voices", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyGETModelHandler)
|
pm.ginEngine.GET("/v1/audio/voices", pm.apiKeyAuth(), pm.proxyGETModelHandler)
|
||||||
pm.ginEngine.POST("/v1/audio/transcriptions", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyOAIPostFormHandler)
|
pm.ginEngine.POST("/v1/audio/transcriptions", pm.apiKeyAuth(), pm.proxyOAIPostFormHandler)
|
||||||
pm.ginEngine.POST("/v1/images/generations", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyInferenceHandler)
|
pm.ginEngine.POST("/v1/images/generations", pm.apiKeyAuth(), pm.proxyInferenceHandler)
|
||||||
pm.ginEngine.POST("/v1/images/edits", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyOAIPostFormHandler)
|
pm.ginEngine.POST("/v1/images/edits", pm.apiKeyAuth(), pm.proxyOAIPostFormHandler)
|
||||||
|
|
||||||
pm.ginEngine.GET("/v1/models", pm.apiKeyAuth(), pm.listModelsHandler)
|
pm.ginEngine.GET("/v1/models", pm.apiKeyAuth(), pm.listModelsHandler)
|
||||||
|
|
||||||
@@ -363,7 +325,7 @@ func (pm *ProxyManager) setupGinEngine() {
|
|||||||
pm.ginEngine.GET("/upstream", func(c *gin.Context) {
|
pm.ginEngine.GET("/upstream", func(c *gin.Context) {
|
||||||
c.Redirect(http.StatusFound, "/ui/models")
|
c.Redirect(http.StatusFound, "/ui/models")
|
||||||
})
|
})
|
||||||
pm.ginEngine.Any("/upstream/*upstreamPath", pm.apiKeyAuth(), pm.trackInflight(), pm.proxyToUpstream)
|
pm.ginEngine.Any("/upstream/*upstreamPath", pm.apiKeyAuth(), pm.proxyToUpstream)
|
||||||
pm.ginEngine.GET("/unload", pm.apiKeyAuth(), pm.unloadAllModelsHandler)
|
pm.ginEngine.GET("/unload", pm.apiKeyAuth(), pm.unloadAllModelsHandler)
|
||||||
pm.ginEngine.GET("/running", pm.apiKeyAuth(), pm.listRunningProcessesHandler)
|
pm.ginEngine.GET("/running", pm.apiKeyAuth(), pm.listRunningProcessesHandler)
|
||||||
pm.ginEngine.GET("/health", func(c *gin.Context) {
|
pm.ginEngine.GET("/health", func(c *gin.Context) {
|
||||||
@@ -427,14 +389,6 @@ func (pm *ProxyManager) setupGinEngine() {
|
|||||||
gin.DisableConsoleColor()
|
gin.DisableConsoleColor()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (pm *ProxyManager) trackInflight() gin.HandlerFunc {
|
|
||||||
return func(c *gin.Context) {
|
|
||||||
event.Emit(InFlightRequestsEvent{Total: pm.inFlightCounter.Increment()})
|
|
||||||
defer event.Emit(InFlightRequestsEvent{Total: pm.inFlightCounter.Decrement()})
|
|
||||||
c.Next()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServeHTTP implements http.Handler interface
|
// ServeHTTP implements http.Handler interface
|
||||||
func (pm *ProxyManager) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (pm *ProxyManager) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
pm.ginEngine.ServeHTTP(w, r)
|
pm.ginEngine.ServeHTTP(w, r)
|
||||||
|
|||||||
@@ -107,7 +107,6 @@ const (
|
|||||||
msgTypeModelStatus messageType = "modelStatus"
|
msgTypeModelStatus messageType = "modelStatus"
|
||||||
msgTypeLogData messageType = "logData"
|
msgTypeLogData messageType = "logData"
|
||||||
msgTypeMetrics messageType = "metrics"
|
msgTypeMetrics messageType = "metrics"
|
||||||
msgTypeInFlight messageType = "inflight"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type messageEnvelope struct {
|
type messageEnvelope struct {
|
||||||
@@ -167,18 +166,6 @@ func (pm *ProxyManager) apiSendEvents(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sendInFlight := func(total int) {
|
|
||||||
jsonData, err := json.Marshal(gin.H{"total": total})
|
|
||||||
if err == nil {
|
|
||||||
select {
|
|
||||||
case sendBuffer <- messageEnvelope{Type: msgTypeInFlight, Data: string(jsonData)}:
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send updated models list
|
* Send updated models list
|
||||||
*/
|
*/
|
||||||
@@ -206,19 +193,11 @@ func (pm *ProxyManager) apiSendEvents(c *gin.Context) {
|
|||||||
sendMetrics([]TokenMetrics{e.Metrics})
|
sendMetrics([]TokenMetrics{e.Metrics})
|
||||||
})()
|
})()
|
||||||
|
|
||||||
/**
|
|
||||||
* Send in-flight request stats related to token stats "Waiting: N" count.
|
|
||||||
*/
|
|
||||||
defer event.On(func(e InFlightRequestsEvent) {
|
|
||||||
sendInFlight(e.Total)
|
|
||||||
})()
|
|
||||||
|
|
||||||
// send initial batch of data
|
// send initial batch of data
|
||||||
sendLogData("proxy", pm.proxyLogger.GetHistory())
|
sendLogData("proxy", pm.proxyLogger.GetHistory())
|
||||||
sendLogData("upstream", pm.upstreamLogger.GetHistory())
|
sendLogData("upstream", pm.upstreamLogger.GetHistory())
|
||||||
sendModels()
|
sendModels()
|
||||||
sendMetrics(pm.metricsMonitor.getMetrics())
|
sendMetrics(pm.metricsMonitor.getMetrics())
|
||||||
sendInFlight(pm.inFlightCounter.Current())
|
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
|
|||||||
@@ -6,28 +6,23 @@
|
|||||||
import Models from "./routes/Models.svelte";
|
import Models from "./routes/Models.svelte";
|
||||||
import Activity from "./routes/Activity.svelte";
|
import Activity from "./routes/Activity.svelte";
|
||||||
import Playground from "./routes/Playground.svelte";
|
import Playground from "./routes/Playground.svelte";
|
||||||
import PlaygroundStub from "./routes/PlaygroundStub.svelte";
|
|
||||||
import { enableAPIEvents } from "./stores/api";
|
import { enableAPIEvents } from "./stores/api";
|
||||||
import { initScreenWidth, isDarkMode, appTitle, connectionState } from "./stores/theme";
|
import { initScreenWidth, isDarkMode, appTitle, connectionState } from "./stores/theme";
|
||||||
import { currentRoute } from "./stores/route";
|
|
||||||
|
|
||||||
const routes = {
|
const routes = {
|
||||||
"/": PlaygroundStub,
|
"/": Playground,
|
||||||
"/models": Models,
|
"/models": Models,
|
||||||
"/logs": LogViewer,
|
"/logs": LogViewer,
|
||||||
"/activity": Activity,
|
"/activity": Activity,
|
||||||
"*": PlaygroundStub,
|
"*": Playground,
|
||||||
};
|
};
|
||||||
|
|
||||||
function handleRouteLoaded(event: { detail: { route: string | RegExp } }) {
|
// Sync theme to document attribute
|
||||||
const route = event.detail.route;
|
|
||||||
currentRoute.set(typeof route === "string" ? route : "/");
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
document.documentElement.setAttribute("data-theme", $isDarkMode ? "dark" : "light");
|
document.documentElement.setAttribute("data-theme", $isDarkMode ? "dark" : "light");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Sync title to document
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const icon = $connectionState === "connecting" ? "\u{1F7E1}" : $connectionState === "connected" ? "\u{1F7E2}" : "\u{1F534}";
|
const icon = $connectionState === "connecting" ? "\u{1F7E1}" : $connectionState === "connected" ? "\u{1F7E2}" : "\u{1F534}";
|
||||||
document.title = `${icon} ${$appTitle}`;
|
document.title = `${icon} ${$appTitle}`;
|
||||||
@@ -48,11 +43,6 @@
|
|||||||
<Header />
|
<Header />
|
||||||
|
|
||||||
<main class="flex-1 overflow-auto p-4">
|
<main class="flex-1 overflow-auto p-4">
|
||||||
<div class="h-full" class:hidden={$currentRoute !== "/"}>
|
<Router {routes} />
|
||||||
<Playground />
|
|
||||||
</div>
|
|
||||||
<div class="h-full" class:hidden={$currentRoute === "/"}>
|
|
||||||
<Router {routes} on:routeLoaded={handleRouteLoaded} />
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { link } from "svelte-spa-router";
|
import { link, location } from "svelte-spa-router";
|
||||||
import { screenWidth, toggleTheme, isDarkMode, appTitle, isNarrow } from "../stores/theme";
|
import { screenWidth, toggleTheme, isDarkMode, appTitle, isNarrow } from "../stores/theme";
|
||||||
import { currentRoute } from "../stores/route";
|
|
||||||
import { playgroundActivity } from "../stores/playgroundActivity";
|
|
||||||
import ConnectionStatus from "./ConnectionStatus.svelte";
|
import ConnectionStatus from "./ConnectionStatus.svelte";
|
||||||
|
|
||||||
function handleTitleChange(newTitle: string): void {
|
function handleTitleChange(newTitle: string): void {
|
||||||
@@ -24,10 +22,9 @@
|
|||||||
handleTitleChange(target.textContent || "(set title)");
|
handleTitleChange(target.textContent || "(set title)");
|
||||||
}
|
}
|
||||||
|
|
||||||
function isActive(path: string, current: string): boolean {
|
function isActive(path: string, currentLocation: string): boolean {
|
||||||
return path === "/" ? current === "/" : current.startsWith(path);
|
return path === "/" ? currentLocation === "/" : currentLocation.startsWith(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<header
|
<header
|
||||||
@@ -50,7 +47,8 @@
|
|||||||
<a
|
<a
|
||||||
href="/"
|
href="/"
|
||||||
use:link
|
use:link
|
||||||
class="p-1 whitespace-nowrap {isActive('/', $currentRoute) ? 'font-semibold' : ''} {$playgroundActivity ? 'activity-link' : 'text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100'}"
|
class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 whitespace-nowrap"
|
||||||
|
class:font-semibold={isActive("/", $location)}
|
||||||
>
|
>
|
||||||
Playground
|
Playground
|
||||||
</a>
|
</a>
|
||||||
@@ -58,7 +56,7 @@
|
|||||||
href="/models"
|
href="/models"
|
||||||
use:link
|
use:link
|
||||||
class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 whitespace-nowrap"
|
class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 whitespace-nowrap"
|
||||||
class:font-semibold={isActive("/models", $currentRoute)}
|
class:font-semibold={isActive("/models", $location)}
|
||||||
>
|
>
|
||||||
Models
|
Models
|
||||||
</a>
|
</a>
|
||||||
@@ -66,7 +64,7 @@
|
|||||||
href="/activity"
|
href="/activity"
|
||||||
use:link
|
use:link
|
||||||
class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 whitespace-nowrap"
|
class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 whitespace-nowrap"
|
||||||
class:font-semibold={isActive("/activity", $currentRoute)}
|
class:font-semibold={isActive("/activity", $location)}
|
||||||
>
|
>
|
||||||
Activity
|
Activity
|
||||||
</a>
|
</a>
|
||||||
@@ -74,7 +72,7 @@
|
|||||||
href="/logs"
|
href="/logs"
|
||||||
use:link
|
use:link
|
||||||
class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 whitespace-nowrap"
|
class="text-gray-600 hover:text-black dark:text-gray-300 dark:hover:text-gray-100 p-1 whitespace-nowrap"
|
||||||
class:font-semibold={isActive("/logs", $currentRoute)}
|
class:font-semibold={isActive("/logs", $location)}
|
||||||
>
|
>
|
||||||
Logs
|
Logs
|
||||||
</a>
|
</a>
|
||||||
@@ -98,23 +96,3 @@
|
|||||||
<ConnectionStatus />
|
<ConnectionStatus />
|
||||||
</menu>
|
</menu>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<style>
|
|
||||||
.activity-link {
|
|
||||||
background: linear-gradient(90deg, #6366f1, #8b5cf6, #a855f7, #8b5cf6, #6366f1);
|
|
||||||
background-size: 200% 100%;
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
animation: gradient-shift 2s linear infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes gradient-shift {
|
|
||||||
0% {
|
|
||||||
background-position: 0% 50%;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
background-position: 200% 50%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { inFlightRequests, metrics } from "../stores/api";
|
import { metrics } from "../stores/api";
|
||||||
import TokenHistogram from "./TokenHistogram.svelte";
|
import TokenHistogram from "./TokenHistogram.svelte";
|
||||||
|
|
||||||
interface HistogramData {
|
interface HistogramData {
|
||||||
@@ -15,14 +15,7 @@
|
|||||||
let stats = $derived.by(() => {
|
let stats = $derived.by(() => {
|
||||||
const totalRequests = $metrics.length;
|
const totalRequests = $metrics.length;
|
||||||
if (totalRequests === 0) {
|
if (totalRequests === 0) {
|
||||||
return {
|
return { totalRequests: 0, totalInputTokens: 0, totalOutputTokens: 0, tokenStats: { p99: "0", p95: "0", p50: "0" }, histogramData: null };
|
||||||
totalRequests: 0,
|
|
||||||
totalInputTokens: 0,
|
|
||||||
totalOutputTokens: 0,
|
|
||||||
inFlightRequests: $inFlightRequests,
|
|
||||||
tokenStats: { p99: "0", p95: "0", p50: "0" },
|
|
||||||
histogramData: null,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalInputTokens = $metrics.reduce((sum, m) => sum + m.input_tokens, 0);
|
const totalInputTokens = $metrics.reduce((sum, m) => sum + m.input_tokens, 0);
|
||||||
@@ -31,14 +24,7 @@
|
|||||||
// Calculate token statistics using output_tokens and duration_ms
|
// Calculate token statistics using output_tokens and duration_ms
|
||||||
const validMetrics = $metrics.filter((m) => m.duration_ms > 0 && m.output_tokens > 0);
|
const validMetrics = $metrics.filter((m) => m.duration_ms > 0 && m.output_tokens > 0);
|
||||||
if (validMetrics.length === 0) {
|
if (validMetrics.length === 0) {
|
||||||
return {
|
return { totalRequests, totalInputTokens, totalOutputTokens, tokenStats: { p99: "0", p95: "0", p50: "0" }, histogramData: null };
|
||||||
totalRequests,
|
|
||||||
totalInputTokens,
|
|
||||||
totalOutputTokens,
|
|
||||||
inFlightRequests: $inFlightRequests,
|
|
||||||
tokenStats: { p99: "0", p95: "0", p50: "0" },
|
|
||||||
histogramData: null,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate tokens/second for each valid metric
|
// Calculate tokens/second for each valid metric
|
||||||
@@ -77,7 +63,6 @@
|
|||||||
totalRequests,
|
totalRequests,
|
||||||
totalInputTokens,
|
totalInputTokens,
|
||||||
totalOutputTokens,
|
totalOutputTokens,
|
||||||
inFlightRequests: $inFlightRequests,
|
|
||||||
tokenStats: {
|
tokenStats: {
|
||||||
p99: p99.toFixed(2),
|
p99: p99.toFixed(2),
|
||||||
p95: p95.toFixed(2),
|
p95: p95.toFixed(2),
|
||||||
@@ -110,12 +95,7 @@
|
|||||||
|
|
||||||
<tbody class="bg-surface divide-y divide-card-border-inner">
|
<tbody class="bg-surface divide-y divide-card-border-inner">
|
||||||
<tr class="hover:bg-secondary">
|
<tr class="hover:bg-secondary">
|
||||||
<td class="px-4 py-4 text-sm font-semibold text-gray-900 dark:text-white">
|
<td class="px-4 py-4 text-sm font-semibold text-gray-900 dark:text-white">{stats.totalRequests}</td>
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">Completed: {nf.format(stats.totalRequests)}</span>
|
|
||||||
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">Waiting: {nf.format(stats.inFlightRequests)}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td class="px-4 py-4 text-sm text-gray-700 dark:text-gray-300 border-l border-gray-200 dark:border-white/10">
|
<td class="px-4 py-4 text-sm text-gray-700 dark:text-gray-300 border-l border-gray-200 dark:border-white/10">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import { models } from "../../stores/api";
|
import { models } from "../../stores/api";
|
||||||
import { persistentStore } from "../../stores/persistent";
|
import { persistentStore } from "../../stores/persistent";
|
||||||
import { transcribeAudio } from "../../lib/audioApi";
|
import { transcribeAudio } from "../../lib/audioApi";
|
||||||
import { playgroundStores } from "../../stores/playgroundActivity";
|
|
||||||
import ModelSelector from "./ModelSelector.svelte";
|
import ModelSelector from "./ModelSelector.svelte";
|
||||||
|
|
||||||
const selectedModelStore = persistentStore<string>("playground-audio-model", "");
|
const selectedModelStore = persistentStore<string>("playground-audio-model", "");
|
||||||
@@ -23,10 +22,6 @@
|
|||||||
|
|
||||||
let canTranscribe = $derived(selectedFile !== null && $selectedModelStore !== "" && !isTranscribing);
|
let canTranscribe = $derived(selectedFile !== null && $selectedModelStore !== "" && !isTranscribing);
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
playgroundStores.audioTranscribing.set(isTranscribing);
|
|
||||||
});
|
|
||||||
|
|
||||||
function validateFile(file: File): { valid: boolean; error?: string } {
|
function validateFile(file: File): { valid: boolean; error?: string } {
|
||||||
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
|
const ext = '.' + file.name.split('.').pop()?.toLowerCase();
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import { models } from "../../stores/api";
|
import { models } from "../../stores/api";
|
||||||
import { persistentStore } from "../../stores/persistent";
|
import { persistentStore } from "../../stores/persistent";
|
||||||
import { streamChatCompletion } from "../../lib/chatApi";
|
import { streamChatCompletion } from "../../lib/chatApi";
|
||||||
import { playgroundStores } from "../../stores/playgroundActivity";
|
|
||||||
import type { ChatMessage, ContentPart } from "../../lib/types";
|
import type { ChatMessage, ContentPart } from "../../lib/types";
|
||||||
import ChatMessageComponent from "./ChatMessage.svelte";
|
import ChatMessageComponent from "./ChatMessage.svelte";
|
||||||
import ModelSelector from "./ModelSelector.svelte";
|
import ModelSelector from "./ModelSelector.svelte";
|
||||||
@@ -12,16 +11,7 @@
|
|||||||
const systemPromptStore = persistentStore<string>("playground-system-prompt", "");
|
const systemPromptStore = persistentStore<string>("playground-system-prompt", "");
|
||||||
const temperatureStore = persistentStore<number>("playground-temperature", 0.7);
|
const temperatureStore = persistentStore<number>("playground-temperature", 0.7);
|
||||||
|
|
||||||
function loadMessages(): ChatMessage[] {
|
let messages = $state<ChatMessage[]>([]);
|
||||||
try {
|
|
||||||
const saved = localStorage.getItem("playground-messages");
|
|
||||||
return saved ? JSON.parse(saved) : [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let messages = $state<ChatMessage[]>(loadMessages());
|
|
||||||
let userInput = $state("");
|
let userInput = $state("");
|
||||||
let isStreaming = $state(false);
|
let isStreaming = $state(false);
|
||||||
let isReasoning = $state(false);
|
let isReasoning = $state(false);
|
||||||
@@ -34,52 +24,21 @@
|
|||||||
let imageError = $state<string | null>(null);
|
let imageError = $state<string | null>(null);
|
||||||
|
|
||||||
let hasModels = $derived($models.some((m) => !m.unlisted));
|
let hasModels = $derived($models.some((m) => !m.unlisted));
|
||||||
let userScrolledUp = $state(false);
|
|
||||||
|
|
||||||
|
// Auto-scroll when messages change
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
playgroundStores.chatStreaming.set(isStreaming);
|
if (messages.length > 0 && messagesContainer) {
|
||||||
});
|
|
||||||
|
|
||||||
function handleMessagesScroll() {
|
|
||||||
if (!messagesContainer) return;
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = messagesContainer;
|
|
||||||
// Consider "at bottom" if within 40px of the bottom
|
|
||||||
userScrolledUp = scrollHeight - scrollTop - clientHeight > 40;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-scroll when messages change — skip if user scrolled up
|
|
||||||
$effect(() => {
|
|
||||||
if (messages.length > 0 && messagesContainer && !userScrolledUp) {
|
|
||||||
messagesContainer.scrollTo({
|
messagesContainer.scrollTo({
|
||||||
top: messagesContainer.scrollHeight,
|
top: messagesContainer.scrollHeight,
|
||||||
behavior: isStreaming ? "instant" : "smooth",
|
behavior: "smooth",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Persist messages to localStorage (throttled to once per 2s)
|
|
||||||
let lastSaveTime = 0;
|
|
||||||
$effect(() => {
|
|
||||||
const json = JSON.stringify(messages);
|
|
||||||
const elapsed = Date.now() - lastSaveTime;
|
|
||||||
const save = () => {
|
|
||||||
try { localStorage.setItem("playground-messages", json); } catch {}
|
|
||||||
lastSaveTime = Date.now();
|
|
||||||
};
|
|
||||||
if (elapsed >= 2000) {
|
|
||||||
save();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const timer = setTimeout(save, 2000 - elapsed);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function sendMessage() {
|
async function sendMessage() {
|
||||||
const trimmedInput = userInput.trim();
|
const trimmedInput = userInput.trim();
|
||||||
if ((!trimmedInput && attachedImages.length === 0) || !$selectedModelStore || isStreaming) return;
|
if ((!trimmedInput && attachedImages.length === 0) || !$selectedModelStore || isStreaming) return;
|
||||||
|
|
||||||
userScrolledUp = false;
|
|
||||||
|
|
||||||
// Build message content (multimodal if images attached)
|
// Build message content (multimodal if images attached)
|
||||||
let content: string | ContentPart[];
|
let content: string | ContentPart[];
|
||||||
if (attachedImages.length > 0) {
|
if (attachedImages.length > 0) {
|
||||||
@@ -362,7 +321,6 @@
|
|||||||
<div
|
<div
|
||||||
class="flex-1 overflow-y-auto mb-4 px-2"
|
class="flex-1 overflow-y-auto mb-4 px-2"
|
||||||
bind:this={messagesContainer}
|
bind:this={messagesContainer}
|
||||||
onscroll={handleMessagesScroll}
|
|
||||||
>
|
>
|
||||||
{#if messages.length === 0}
|
{#if messages.length === 0}
|
||||||
<div class="h-full flex items-center justify-center text-txtsecondary">
|
<div class="h-full flex items-center justify-center text-txtsecondary">
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { renderMarkdown, escapeHtml, renderStreamingMarkdown, createStreamingCache } from "../../lib/markdown";
|
import { renderMarkdown, escapeHtml } from "../../lib/markdown";
|
||||||
import type { RenderedBlock } from "../../lib/markdown";
|
|
||||||
import { Copy, Check, Pencil, X, Save, RefreshCw, ChevronDown, ChevronRight, Brain, Code } from "lucide-svelte";
|
import { Copy, Check, Pencil, X, Save, RefreshCw, ChevronDown, ChevronRight, Brain, Code } from "lucide-svelte";
|
||||||
import { getTextContent, getImageUrls } from "../../lib/types";
|
import { getTextContent, getImageUrls } from "../../lib/types";
|
||||||
import type { ContentPart } from "../../lib/types";
|
import type { ContentPart } from "../../lib/types";
|
||||||
@@ -23,17 +22,11 @@
|
|||||||
let hasImages = $derived(imageUrls.length > 0);
|
let hasImages = $derived(imageUrls.length > 0);
|
||||||
let canEdit = $derived(onEdit !== undefined && !hasImages);
|
let canEdit = $derived(onEdit !== undefined && !hasImages);
|
||||||
|
|
||||||
let streamingCache = createStreamingCache();
|
let renderedContent = $derived(
|
||||||
let renderedParts = $derived.by(() => {
|
role === "assistant" && !isStreaming
|
||||||
if (role !== "assistant") {
|
? renderMarkdown(textContent)
|
||||||
return { blocks: [{ id: -1, html: escapeHtml(textContent).replace(/\n/g, '<br>') }] as RenderedBlock[], pendingHtml: "" };
|
: escapeHtml(textContent).replace(/\n/g, '<br>')
|
||||||
}
|
);
|
||||||
if (!isStreaming) {
|
|
||||||
streamingCache = createStreamingCache();
|
|
||||||
return { blocks: [{ id: -1, html: renderMarkdown(textContent) }] as RenderedBlock[], pendingHtml: "" };
|
|
||||||
}
|
|
||||||
return renderStreamingMarkdown(textContent, streamingCache);
|
|
||||||
});
|
|
||||||
let copied = $state(false);
|
let copied = $state(false);
|
||||||
let showRaw = $state(false);
|
let showRaw = $state(false);
|
||||||
let isEditing = $state(false);
|
let isEditing = $state(false);
|
||||||
@@ -120,9 +113,9 @@
|
|||||||
|
|
||||||
<div class="flex {role === 'user' ? 'justify-end' : 'justify-start'} mb-4">
|
<div class="flex {role === 'user' ? 'justify-end' : 'justify-start'} mb-4">
|
||||||
<div
|
<div
|
||||||
class="relative group rounded-lg px-4 py-2 {role === 'user'
|
class="relative group max-w-[85%] rounded-lg px-4 py-2 {role === 'user'
|
||||||
? 'max-w-[85%] bg-primary text-btn-primary-text'
|
? 'bg-primary text-btn-primary-text'
|
||||||
: 'w-full sm:w-4/5 bg-surface border border-gray-200 dark:border-white/10'}"
|
: 'bg-surface border border-gray-200 dark:border-white/10'}"
|
||||||
>
|
>
|
||||||
{#if role === "assistant"}
|
{#if role === "assistant"}
|
||||||
{#if reasoning_content || isReasoning}
|
{#if reasoning_content || isReasoning}
|
||||||
@@ -175,10 +168,7 @@
|
|||||||
<div class="whitespace-pre-wrap font-mono text-sm">{textContent}</div>
|
<div class="whitespace-pre-wrap font-mono text-sm">{textContent}</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="prose prose-sm dark:prose-invert max-w-none">
|
<div class="prose prose-sm dark:prose-invert max-w-none">
|
||||||
{#each renderedParts.blocks as block (block.id)}
|
{@html renderedContent}
|
||||||
{@html block.html}
|
|
||||||
{/each}
|
|
||||||
{@html renderedParts.pendingHtml}
|
|
||||||
{#if isStreaming && !isReasoning}
|
{#if isStreaming && !isReasoning}
|
||||||
<span class="inline-block w-2 h-4 bg-current animate-pulse ml-0.5"></span>
|
<span class="inline-block w-2 h-4 bg-current animate-pulse ml-0.5"></span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import { models } from "../../stores/api";
|
import { models } from "../../stores/api";
|
||||||
import { persistentStore } from "../../stores/persistent";
|
import { persistentStore } from "../../stores/persistent";
|
||||||
import { generateImage } from "../../lib/imageApi";
|
import { generateImage } from "../../lib/imageApi";
|
||||||
import { playgroundStores } from "../../stores/playgroundActivity";
|
|
||||||
import ModelSelector from "./ModelSelector.svelte";
|
import ModelSelector from "./ModelSelector.svelte";
|
||||||
import ExpandableTextarea from "./ExpandableTextarea.svelte";
|
import ExpandableTextarea from "./ExpandableTextarea.svelte";
|
||||||
|
|
||||||
@@ -18,10 +17,6 @@
|
|||||||
|
|
||||||
let hasModels = $derived($models.some((m) => !m.unlisted));
|
let hasModels = $derived($models.some((m) => !m.unlisted));
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
playgroundStores.imageGenerating.set(isGenerating);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function generate() {
|
async function generate() {
|
||||||
const trimmedPrompt = prompt.trim();
|
const trimmedPrompt = prompt.trim();
|
||||||
if (!trimmedPrompt || !$selectedModelStore || isGenerating) return;
|
if (!trimmedPrompt || !$selectedModelStore || isGenerating) return;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import { models } from "../../stores/api";
|
import { models } from "../../stores/api";
|
||||||
import { persistentStore } from "../../stores/persistent";
|
import { persistentStore } from "../../stores/persistent";
|
||||||
import { generateSpeech } from "../../lib/speechApi";
|
import { generateSpeech } from "../../lib/speechApi";
|
||||||
import { playgroundStores } from "../../stores/playgroundActivity";
|
|
||||||
import ModelSelector from "./ModelSelector.svelte";
|
import ModelSelector from "./ModelSelector.svelte";
|
||||||
import ExpandableTextarea from "./ExpandableTextarea.svelte";
|
import ExpandableTextarea from "./ExpandableTextarea.svelte";
|
||||||
|
|
||||||
@@ -21,9 +20,11 @@
|
|||||||
let availableVoices = $state<string[]>(["coral", "alloy", "echo", "fable", "onyx", "nova", "shimmer"]);
|
let availableVoices = $state<string[]>(["coral", "alloy", "echo", "fable", "onyx", "nova", "shimmer"]);
|
||||||
let isLoadingVoices = $state(false);
|
let isLoadingVoices = $state(false);
|
||||||
|
|
||||||
|
// Default voices to fall back to if API call fails
|
||||||
const defaultVoices = ["coral", "alloy", "echo", "fable", "onyx", "nova", "shimmer"];
|
const defaultVoices = ["coral", "alloy", "echo", "fable", "onyx", "nova", "shimmer"];
|
||||||
const CACHE_KEY = "playground-speech-voices-cache";
|
const CACHE_KEY = "playground-speech-voices-cache";
|
||||||
|
|
||||||
|
// Load voices cache from localStorage
|
||||||
function getVoicesCache(): Record<string, string[]> {
|
function getVoicesCache(): Record<string, string[]> {
|
||||||
if (typeof window === "undefined") return {};
|
if (typeof window === "undefined") return {};
|
||||||
try {
|
try {
|
||||||
@@ -34,6 +35,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Save voices cache to localStorage
|
||||||
function saveVoicesCache(cache: Record<string, string[]>) {
|
function saveVoicesCache(cache: Record<string, string[]>) {
|
||||||
if (typeof window === "undefined") return;
|
if (typeof window === "undefined") return;
|
||||||
try {
|
try {
|
||||||
@@ -45,12 +47,9 @@
|
|||||||
|
|
||||||
let hasModels = $derived($models.some((m) => !m.unlisted));
|
let hasModels = $derived($models.some((m) => !m.unlisted));
|
||||||
|
|
||||||
|
// Track if this is the initial page load to avoid fetching on refresh
|
||||||
let isInitialLoad = $state(true);
|
let isInitialLoad = $state(true);
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
playgroundStores.speechGenerating.set(isGenerating);
|
|
||||||
});
|
|
||||||
|
|
||||||
// On page load, restore cached voices for the selected model if available
|
// On page load, restore cached voices for the selected model if available
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const model = $selectedModelStore;
|
const model = $selectedModelStore;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { renderMarkdown, escapeHtml, splitCompleteBlocks, closePendingBlock, normalizeLatexDelimiters, renderStreamingMarkdown, createStreamingCache } from "./markdown";
|
import { renderMarkdown, escapeHtml } from "./markdown";
|
||||||
|
|
||||||
describe("renderMarkdown", () => {
|
describe("renderMarkdown", () => {
|
||||||
describe("basic markdown", () => {
|
describe("basic markdown", () => {
|
||||||
@@ -130,35 +130,6 @@ More text here.
|
|||||||
expect(result).toContain("katex");
|
expect(result).toContain("katex");
|
||||||
expect(result).toContain("sqrt");
|
expect(result).toContain("sqrt");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders \\[...\\] display math", () => {
|
|
||||||
const result = renderMarkdown("\\[\nx^2 + y^2 = z^2\n\\]");
|
|
||||||
expect(result).toContain("katex");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders \\(...\\) inline math", () => {
|
|
||||||
const result = renderMarkdown("The equation \\(E = mc^2\\) is famous.");
|
|
||||||
expect(result).toContain("katex");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("normalizeLatexDelimiters", () => {
|
|
||||||
it("converts \\[...\\] to $$...$$", () => {
|
|
||||||
expect(normalizeLatexDelimiters("\\[\nx^2\n\\]")).toBe("$$\nx^2\n$$");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("converts \\(...\\) to $...$", () => {
|
|
||||||
expect(normalizeLatexDelimiters("\\(x^2\\)")).toBe("$x^2$");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves $$ and $ delimiters unchanged", () => {
|
|
||||||
const text = "$$x^2$$ and $y$";
|
|
||||||
expect(normalizeLatexDelimiters(text)).toBe(text);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles multiple occurrences", () => {
|
|
||||||
expect(normalizeLatexDelimiters("\\(a\\) and \\(b\\)")).toBe("$a$ and $b$");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("escapeHtml", () => {
|
describe("escapeHtml", () => {
|
||||||
@@ -187,237 +158,3 @@ More text here.
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("splitCompleteBlocks", () => {
|
|
||||||
it("returns everything as pending when no blank line", () => {
|
|
||||||
const result = splitCompleteBlocks("Hello world");
|
|
||||||
expect(result.complete).toBe("");
|
|
||||||
expect(result.pending).toBe("Hello world");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns empty for empty input", () => {
|
|
||||||
const result = splitCompleteBlocks("");
|
|
||||||
expect(result.complete).toBe("");
|
|
||||||
expect(result.pending).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("splits on blank line between paragraphs", () => {
|
|
||||||
const result = splitCompleteBlocks("First paragraph.\n\nSecond paragraph");
|
|
||||||
expect(result.complete).toBe("First paragraph.\n");
|
|
||||||
expect(result.pending).toBe("Second paragraph");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("splits multiple paragraphs at last blank line", () => {
|
|
||||||
const result = splitCompleteBlocks("Para 1.\n\nPara 2.\n\nPara 3");
|
|
||||||
expect(result.complete).toBe("Para 1.\n\nPara 2.\n");
|
|
||||||
expect(result.pending).toBe("Para 3");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats closed code fence as complete boundary", () => {
|
|
||||||
const text = "```js\nconst x = 1;\n```\nMore text";
|
|
||||||
const result = splitCompleteBlocks(text);
|
|
||||||
expect(result.complete).toBe("```js\nconst x = 1;\n```");
|
|
||||||
expect(result.pending).toBe("More text");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats unclosed code fence as pending", () => {
|
|
||||||
const text = "Done paragraph.\n\n```js\nconst x = 1;";
|
|
||||||
const result = splitCompleteBlocks(text);
|
|
||||||
expect(result.complete).toBe("Done paragraph.\n");
|
|
||||||
expect(result.pending).toBe("```js\nconst x = 1;");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not split on blank lines inside code fences", () => {
|
|
||||||
const text = "```\nline1\n\nline2\n```";
|
|
||||||
const result = splitCompleteBlocks(text);
|
|
||||||
expect(result.complete).toBe("```\nline1\n\nline2\n```");
|
|
||||||
expect(result.pending).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles tilde fences", () => {
|
|
||||||
const text = "~~~py\nprint('hi')\n~~~\nAfter";
|
|
||||||
const result = splitCompleteBlocks(text);
|
|
||||||
expect(result.complete).toBe("~~~py\nprint('hi')\n~~~");
|
|
||||||
expect(result.pending).toBe("After");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not close backtick fence with tilde fence", () => {
|
|
||||||
const text = "```\ncode\n~~~\nstill code";
|
|
||||||
const result = splitCompleteBlocks(text);
|
|
||||||
// The ~~~ should not close a backtick fence, so everything from ``` onward is pending
|
|
||||||
expect(result.complete).toBe("");
|
|
||||||
expect(result.pending).toBe("```\ncode\n~~~\nstill code");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats closed math block as complete boundary", () => {
|
|
||||||
const text = "$$\nx^2\n$$\nAfter";
|
|
||||||
const result = splitCompleteBlocks(text);
|
|
||||||
expect(result.complete).toBe("$$\nx^2\n$$");
|
|
||||||
expect(result.pending).toBe("After");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats unclosed math block as pending", () => {
|
|
||||||
const text = "Before.\n\n$$\nx^2";
|
|
||||||
const result = splitCompleteBlocks(text);
|
|
||||||
expect(result.complete).toBe("Before.\n");
|
|
||||||
expect(result.pending).toBe("$$\nx^2");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats closed \\[...\\] math block as complete boundary", () => {
|
|
||||||
const text = "\\[\nx^2\n\\]\nAfter";
|
|
||||||
const result = splitCompleteBlocks(text);
|
|
||||||
expect(result.complete).toBe("\\[\nx^2\n\\]");
|
|
||||||
expect(result.pending).toBe("After");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats unclosed \\[ math block as pending", () => {
|
|
||||||
const text = "Before.\n\n\\[\nx^2";
|
|
||||||
const result = splitCompleteBlocks(text);
|
|
||||||
expect(result.complete).toBe("Before.\n");
|
|
||||||
expect(result.pending).toBe("\\[\nx^2");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("handles trailing blank line making everything complete", () => {
|
|
||||||
const text = "Hello world.\n";
|
|
||||||
const result = splitCompleteBlocks(text);
|
|
||||||
// Last line is empty string after split, which is a blank line
|
|
||||||
expect(result.complete).toBe("Hello world.\n");
|
|
||||||
expect(result.pending).toBe("");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("closePendingBlock", () => {
|
|
||||||
it("returns empty string for empty input", () => {
|
|
||||||
expect(closePendingBlock("")).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns plain text unchanged", () => {
|
|
||||||
expect(closePendingBlock("Hello world")).toBe("Hello world");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("closes an open backtick code fence", () => {
|
|
||||||
const result = closePendingBlock("```python\nprint('hi')");
|
|
||||||
expect(result).toBe("```python\nprint('hi')\n```");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("closes an open tilde code fence", () => {
|
|
||||||
const result = closePendingBlock("~~~js\nconst x = 1;");
|
|
||||||
expect(result).toBe("~~~js\nconst x = 1;\n~~~");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not modify already-closed code fence", () => {
|
|
||||||
const text = "```py\ncode\n```";
|
|
||||||
expect(closePendingBlock(text)).toBe(text);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("closes an open math block", () => {
|
|
||||||
const result = closePendingBlock("$$\nx^2 + y^2");
|
|
||||||
expect(result).toBe("$$\nx^2 + y^2\n$$");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not modify already-closed math block", () => {
|
|
||||||
const text = "$$\nx^2\n$$";
|
|
||||||
expect(closePendingBlock(text)).toBe(text);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("closes an open \\[ math block with \\]", () => {
|
|
||||||
const result = closePendingBlock("\\[\nx^2 + y^2");
|
|
||||||
expect(result).toBe("\\[\nx^2 + y^2\n\\]");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not modify already-closed \\[...\\] math block", () => {
|
|
||||||
const text = "\\[\nx^2\n\\]";
|
|
||||||
expect(closePendingBlock(text)).toBe(text);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("closes code fence when preceded by regular text", () => {
|
|
||||||
const result = closePendingBlock("Some text\n```\ncode");
|
|
||||||
expect(result).toBe("Some text\n```\ncode\n```");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves headers unchanged", () => {
|
|
||||||
expect(closePendingBlock("## Hello")).toBe("## Hello");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves tables unchanged", () => {
|
|
||||||
const table = "| a | b |\n| --- | --- |\n| 1 | 2 |";
|
|
||||||
expect(closePendingBlock(table)).toBe(table);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("leaves lists unchanged", () => {
|
|
||||||
expect(closePendingBlock("- item 1\n- item 2")).toBe("- item 1\n- item 2");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("renderStreamingMarkdown", () => {
|
|
||||||
it("renders complete blocks and pending as markdown", () => {
|
|
||||||
const cache = createStreamingCache();
|
|
||||||
const text = "# Hello\n\nWorld";
|
|
||||||
const { blocks, pendingHtml } = renderStreamingMarkdown(text, cache);
|
|
||||||
expect(blocks).toHaveLength(1);
|
|
||||||
expect(blocks[0].html).toContain("<h1>Hello</h1>");
|
|
||||||
expect(pendingHtml).toContain("World");
|
|
||||||
expect(pendingHtml).toContain("<p>");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("preserves existing blocks when complete portion is unchanged", () => {
|
|
||||||
const cache = createStreamingCache();
|
|
||||||
renderStreamingMarkdown("# Hello\n\nWor", cache);
|
|
||||||
const firstBlocks = cache.blocks;
|
|
||||||
|
|
||||||
const { blocks } = renderStreamingMarkdown("# Hello\n\nWorld", cache);
|
|
||||||
// Same block array reference — nothing changed in the complete section
|
|
||||||
expect(blocks).toBe(firstBlocks);
|
|
||||||
expect(cache.completeKey).toBe("# Hello\n");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("appends a new block when a new section completes", () => {
|
|
||||||
const cache = createStreamingCache();
|
|
||||||
renderStreamingMarkdown("# Hello\n\nParagraph", cache);
|
|
||||||
expect(cache.blocks).toHaveLength(1);
|
|
||||||
const firstBlock = cache.blocks[0];
|
|
||||||
|
|
||||||
renderStreamingMarkdown("# Hello\n\nParagraph.\n\nMore", cache);
|
|
||||||
expect(cache.blocks).toHaveLength(2);
|
|
||||||
// First block is preserved with the same id and html
|
|
||||||
expect(cache.blocks[0].id).toBe(firstBlock.id);
|
|
||||||
expect(cache.blocks[0].html).toBe(firstBlock.html);
|
|
||||||
// Second block contains the new paragraph
|
|
||||||
expect(cache.blocks[1].html).toContain("Paragraph.");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("assigns unique stable ids to each block", () => {
|
|
||||||
const cache = createStreamingCache();
|
|
||||||
renderStreamingMarkdown("A.\n\nB.\n\nC", cache);
|
|
||||||
expect(cache.blocks).toHaveLength(1);
|
|
||||||
const id0 = cache.blocks[0].id;
|
|
||||||
|
|
||||||
renderStreamingMarkdown("A.\n\nB.\n\nC.\n\nD", cache);
|
|
||||||
expect(cache.blocks).toHaveLength(2);
|
|
||||||
expect(cache.blocks[0].id).toBe(id0);
|
|
||||||
expect(cache.blocks[1].id).toBe(id0 + 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders pending code block with syntax highlighting", () => {
|
|
||||||
const cache = createStreamingCache();
|
|
||||||
const text = "Done.\n\n```python\nprint('hello')";
|
|
||||||
const { pendingHtml } = renderStreamingMarkdown(text, cache);
|
|
||||||
expect(pendingHtml).toContain("<code");
|
|
||||||
expect(pendingHtml).toContain("hljs");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders pending table as markdown", () => {
|
|
||||||
const cache = createStreamingCache();
|
|
||||||
const text = "Done.\n\n| a | b |\n| --- | --- |\n| 1 | 2 |";
|
|
||||||
const { pendingHtml } = renderStreamingMarkdown(text, cache);
|
|
||||||
expect(pendingHtml).toContain("<table>");
|
|
||||||
expect(pendingHtml).toContain("<td>");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("renders pending portion through markdown pipeline", () => {
|
|
||||||
const cache = createStreamingCache();
|
|
||||||
const text = "Done.\n\nSome **bold** text";
|
|
||||||
const { pendingHtml } = renderStreamingMarkdown(text, cache);
|
|
||||||
expect(pendingHtml).toContain("<strong>bold</strong>");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -69,189 +69,13 @@ const processor = unified()
|
|||||||
.use(rehypeHighlight)
|
.use(rehypeHighlight)
|
||||||
.use(rehypeStringify, { allowDangerousHtml: true });
|
.use(rehypeStringify, { allowDangerousHtml: true });
|
||||||
|
|
||||||
export function splitCompleteBlocks(text: string): { complete: string; pending: string } {
|
|
||||||
if (!text) {
|
|
||||||
return { complete: "", pending: "" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = text.split("\n");
|
|
||||||
let lastCompleteBoundary = -1; // index of last line that ends a complete block
|
|
||||||
let inFence = false;
|
|
||||||
let fenceChar = "";
|
|
||||||
let inMathBlock = false;
|
|
||||||
|
|
||||||
for (let i = 0; i < lines.length; i++) {
|
|
||||||
const trimmed = lines[i].trimEnd();
|
|
||||||
|
|
||||||
if (inFence) {
|
|
||||||
// Check for closing fence: same character, at least 3, no other content
|
|
||||||
if (new RegExp(`^\\s*${fenceChar.replace(/~/g, "\\~")}{3,}\\s*$`).test(trimmed)) {
|
|
||||||
inFence = false;
|
|
||||||
fenceChar = "";
|
|
||||||
lastCompleteBoundary = i;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inMathBlock) {
|
|
||||||
if (trimmed === "$$" || trimmed === "\\]") {
|
|
||||||
inMathBlock = false;
|
|
||||||
lastCompleteBoundary = i;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for opening fence
|
|
||||||
const fenceMatch = trimmed.match(/^(\s*)(```|~~~)/);
|
|
||||||
if (fenceMatch) {
|
|
||||||
// Check if it's an opening fence (may have language info after)
|
|
||||||
// A line with just ``` or ~~~ could be opening or closing, but since we're not in a fence it's opening
|
|
||||||
fenceChar = fenceMatch[2][0]; // '`' or '~'
|
|
||||||
inFence = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for opening math block
|
|
||||||
if (trimmed === "$$" || trimmed === "\\[") {
|
|
||||||
inMathBlock = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Outside fences/math: blank line marks a complete boundary
|
|
||||||
if (trimmed === "") {
|
|
||||||
lastCompleteBoundary = i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lastCompleteBoundary < 0) {
|
|
||||||
return { complete: "", pending: text };
|
|
||||||
}
|
|
||||||
|
|
||||||
const completeLines = lines.slice(0, lastCompleteBoundary + 1);
|
|
||||||
const pendingLines = lines.slice(lastCompleteBoundary + 1);
|
|
||||||
|
|
||||||
return {
|
|
||||||
complete: completeLines.join("\n"),
|
|
||||||
pending: pendingLines.join("\n"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function closePendingBlock(pending: string): string {
|
|
||||||
if (!pending) return "";
|
|
||||||
|
|
||||||
const lines = pending.split("\n");
|
|
||||||
let inFence = false;
|
|
||||||
let fenceStr = "";
|
|
||||||
let inMathBlock = false;
|
|
||||||
let mathClose = "";
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
const trimmed = line.trimEnd();
|
|
||||||
|
|
||||||
if (inFence) {
|
|
||||||
if (new RegExp(`^\\s*${fenceStr[0] === "~" ? "~~~" : "\\`\\`\\`"}\\s*$`).test(trimmed)) {
|
|
||||||
inFence = false;
|
|
||||||
fenceStr = "";
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inMathBlock) {
|
|
||||||
if (trimmed === "$$" || trimmed === "\\]") {
|
|
||||||
inMathBlock = false;
|
|
||||||
mathClose = "";
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fenceMatch = trimmed.match(/^(\s*)(```|~~~)/);
|
|
||||||
if (fenceMatch) {
|
|
||||||
fenceStr = fenceMatch[2];
|
|
||||||
inFence = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trimmed === "$$") {
|
|
||||||
inMathBlock = true;
|
|
||||||
mathClose = "$$";
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trimmed === "\\[") {
|
|
||||||
inMathBlock = true;
|
|
||||||
mathClose = "\\]";
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (inFence) return pending + "\n" + fenceStr;
|
|
||||||
if (inMathBlock) return pending + "\n" + mathClose;
|
|
||||||
return pending;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RenderedBlock {
|
|
||||||
id: number;
|
|
||||||
html: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StreamingCache {
|
|
||||||
blocks: RenderedBlock[];
|
|
||||||
nextId: number;
|
|
||||||
completeKey: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createStreamingCache(): StreamingCache {
|
|
||||||
return { blocks: [], nextId: 0, completeKey: "" };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderStreamingMarkdown(
|
|
||||||
text: string,
|
|
||||||
cache: StreamingCache,
|
|
||||||
): { blocks: RenderedBlock[]; pendingHtml: string } {
|
|
||||||
const { complete, pending } = splitCompleteBlocks(text);
|
|
||||||
|
|
||||||
if (complete) {
|
|
||||||
if (cache.completeKey !== complete) {
|
|
||||||
if (complete.startsWith(cache.completeKey) && cache.completeKey.length > 0) {
|
|
||||||
// Complete section grew — render only the new part as a new block
|
|
||||||
const newPart = complete.slice(cache.completeKey.length);
|
|
||||||
cache.blocks = [...cache.blocks, { id: cache.nextId++, html: renderMarkdown(newPart) }];
|
|
||||||
} else {
|
|
||||||
// Complete section changed unexpectedly — re-render as single block
|
|
||||||
cache.blocks = [{ id: cache.nextId++, html: renderMarkdown(complete) }];
|
|
||||||
}
|
|
||||||
cache.completeKey = complete;
|
|
||||||
}
|
|
||||||
} else if (cache.blocks.length > 0) {
|
|
||||||
cache.blocks = [];
|
|
||||||
cache.completeKey = "";
|
|
||||||
}
|
|
||||||
|
|
||||||
let pendingHtml = "";
|
|
||||||
if (pending) {
|
|
||||||
const closed = closePendingBlock(pending);
|
|
||||||
pendingHtml = renderMarkdown(closed);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { blocks: cache.blocks, pendingHtml };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert \[...\] to $$...$$ and \(...\) to $...$
|
|
||||||
export function normalizeLatexDelimiters(text: string): string {
|
|
||||||
// Display math: \[...\] → $$...$$ (may span multiple lines)
|
|
||||||
text = text.replace(/\\\[([\s\S]*?)\\\]/g, (_match, inner) => `$$${inner}$$`);
|
|
||||||
// Inline math: \(...\) → $...$
|
|
||||||
text = text.replace(/\\\(([\s\S]*?)\\\)/g, (_match, inner) => `$${inner}$`);
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderMarkdown(content: string): string {
|
export function renderMarkdown(content: string): string {
|
||||||
if (!content) {
|
if (!content) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = processor.processSync(normalizeLatexDelimiters(content));
|
const result = processor.processSync(content);
|
||||||
return String(result);
|
return String(result);
|
||||||
} catch {
|
} catch {
|
||||||
// Fallback to escaped plain text if markdown parsing fails
|
// Fallback to escaped plain text if markdown parsing fails
|
||||||
|
|||||||
@@ -38,12 +38,8 @@ export interface LogData {
|
|||||||
data: string;
|
data: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InFlightStats {
|
|
||||||
total: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface APIEventEnvelope {
|
export interface APIEventEnvelope {
|
||||||
type: "modelStatus" | "logData" | "metrics" | "inflight";
|
type: "modelStatus" | "logData" | "metrics";
|
||||||
data: string;
|
data: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
<!-- empty: real Playground is always mounted in App.svelte -->
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { writable } from "svelte/store";
|
import { writable } from "svelte/store";
|
||||||
import type { Model, Metrics, VersionInfo, LogData, APIEventEnvelope, ReqRespCapture, InFlightStats } from "../lib/types";
|
import type { Model, Metrics, VersionInfo, LogData, APIEventEnvelope, ReqRespCapture } from "../lib/types";
|
||||||
import { connectionState } from "./theme";
|
import { connectionState } from "./theme";
|
||||||
|
|
||||||
const LOG_LENGTH_LIMIT = 1024 * 100; /* 100KB of log data */
|
const LOG_LENGTH_LIMIT = 1024 * 100; /* 100KB of log data */
|
||||||
@@ -9,7 +9,6 @@ export const models = writable<Model[]>([]);
|
|||||||
export const proxyLogs = writable<string>("");
|
export const proxyLogs = writable<string>("");
|
||||||
export const upstreamLogs = writable<string>("");
|
export const upstreamLogs = writable<string>("");
|
||||||
export const metrics = writable<Metrics[]>([]);
|
export const metrics = writable<Metrics[]>([]);
|
||||||
export const inFlightRequests = writable<number>(0);
|
|
||||||
export const versionInfo = writable<VersionInfo>({
|
export const versionInfo = writable<VersionInfo>({
|
||||||
build_date: "unknown",
|
build_date: "unknown",
|
||||||
commit: "unknown",
|
commit: "unknown",
|
||||||
@@ -30,7 +29,6 @@ export function enableAPIEvents(enabled: boolean): void {
|
|||||||
apiEventSource?.close();
|
apiEventSource?.close();
|
||||||
apiEventSource = null;
|
apiEventSource = null;
|
||||||
metrics.set([]);
|
metrics.set([]);
|
||||||
inFlightRequests.set(0);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +46,6 @@ export function enableAPIEvents(enabled: boolean): void {
|
|||||||
proxyLogs.set("");
|
proxyLogs.set("");
|
||||||
upstreamLogs.set("");
|
upstreamLogs.set("");
|
||||||
metrics.set([]);
|
metrics.set([]);
|
||||||
inFlightRequests.set(0);
|
|
||||||
models.set([]);
|
models.set([]);
|
||||||
retryCount = 0;
|
retryCount = 0;
|
||||||
connectionState.set("connected");
|
connectionState.set("connected");
|
||||||
@@ -86,11 +83,6 @@ export function enableAPIEvents(enabled: boolean): void {
|
|||||||
metrics.update((prevMetrics) => [...newMetrics, ...prevMetrics]);
|
metrics.update((prevMetrics) => [...newMetrics, ...prevMetrics]);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "inflight": {
|
|
||||||
const stats = JSON.parse(message.data) as InFlightStats;
|
|
||||||
inFlightRequests.set(stats.total ?? 0);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(e.data, err);
|
console.error(e.data, err);
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
import { writable, derived } from "svelte/store";
|
|
||||||
|
|
||||||
const chatStreaming = writable(false);
|
|
||||||
const imageGenerating = writable(false);
|
|
||||||
const speechGenerating = writable(false);
|
|
||||||
const audioTranscribing = writable(false);
|
|
||||||
|
|
||||||
export const playgroundActivity = derived(
|
|
||||||
[chatStreaming, imageGenerating, speechGenerating, audioTranscribing],
|
|
||||||
([$chat, $image, $speech, $audio]) => $chat || $image || $speech || $audio
|
|
||||||
);
|
|
||||||
|
|
||||||
export const playgroundStores = {
|
|
||||||
chatStreaming,
|
|
||||||
imageGenerating,
|
|
||||||
speechGenerating,
|
|
||||||
audioTranscribing,
|
|
||||||
};
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
import { writable } from "svelte/store";
|
|
||||||
|
|
||||||
export const currentRoute = writable("/");
|
|
||||||
Reference in New Issue
Block a user