diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index febcf81..ff783b7 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -64,11 +64,11 @@ tmux attach -t yap - **OpenAI**: Uses OpenAI Whisper API (requires API key) ### Models (Local) -- tiny.en (~75MB) - Fastest -- base.en (~150MB) - Default, good balance -- small.en (~500MB) - Better accuracy -- medium.en (~1.5GB) - High accuracy -- large-v3 (~3GB) - Best accuracy +Supported models, their sizes, and their speed and accuracy ratings are defined +in one catalog in `internal/models/catalog.go`. Users browse, download, activate, +and delete them on the **Models** page. Only one download runs at a time. +Downloading a model never activates it. The one exception is onboarding, which +activates the model the user picked there. ## Building @@ -80,6 +80,24 @@ wails dev wails build ``` +## Testing + +```bash +go test ./... +cd frontend && npm test +``` + +### Isolated development data + +Set `YAP_CONFIG_DIR` to keep config, models, history, and logs out of the +installed app's data directory. Set `YAP_MODEL_BASE_URL` to download models from +a local mirror, e.g. to exercise failed or truncated downloads: + +```bash +YAP_CONFIG_DIR=/tmp/yap-dev YAP_MODEL_BASE_URL=http://127.0.0.1:34199 \ + wails dev -devserver localhost:34135 +``` + ## Requirements - Go 1.21+ diff --git a/app.go b/app.go index b47d3ed..abc846d 100644 --- a/app.go +++ b/app.go @@ -59,13 +59,20 @@ type HistoryItem struct { HasAudio bool `json:"hasAudio"` } -// ModelInfo for frontend +// ModelInfo describes a catalog model and its local availability for the frontend. type ModelInfo struct { - Name string `json:"name"` - DisplayName string `json:"displayName"` - Size string `json:"size"` - Downloaded bool `json:"downloaded"` - EnglishOnly bool `json:"englishOnly"` + Name string `json:"name"` + DisplayName string `json:"displayName"` + Size string `json:"size"` + SizeBytes int64 `json:"sizeBytes"` + Downloaded bool `json:"downloaded"` + EnglishOnly bool `json:"englishOnly"` + Provider string `json:"provider"` + Speed int `json:"speed"` + Accuracy int `json:"accuracy"` + UseCase string `json:"useCase"` + Recommendation string `json:"recommendation"` + Favorite bool `json:"favorite"` } // UsageStats for frontend @@ -89,6 +96,7 @@ type App struct { openaiEngine *transcribe.OpenAIEngine configManager *models.ConfigManager modelManager *models.Manager + downloader *models.Downloader statsManager *models.StatsManager hotkeyManager *hotkey.Manager overlay *overlay.Overlay @@ -180,6 +188,9 @@ func (a *App) startup(ctx context.Context) { return } a.modelManager = modelManager + a.downloader = models.NewDownloader(modelManager, func(state models.DownloadState) { + runtime.EventsEmit(a.ctx, "modelDownloadState", state) + }, a.onModelDownloadFinished) // Initialize transcription engines a.localEngine = transcribe.NewLocalEngine(configManager.GetModelsDir()) @@ -275,6 +286,9 @@ func (a *App) domReady(ctx context.Context) { // shutdown is called when the app closes func (a *App) shutdown(ctx context.Context) { runtime.LogInfo(ctx, "OnShutdown: starting cleanup") + if a.downloader != nil { + a.downloader.Shutdown(5 * time.Second) + } if a.hotkeyManager != nil { a.hotkeyManager.Unregister() } @@ -840,29 +854,37 @@ func (a *App) emitState() { } } -// GetModels returns available models with download status +// GetModels returns every catalog model with its download status func (a *App) GetModels() []ModelInfo { if a.WaitForStartup() != nil { return nil } - available := transcribe.AvailableModels() - result := make([]ModelInfo, len(available)) - - for i, m := range available { + catalog := models.Catalog() + result := make([]ModelInfo, len(catalog)) + for i, spec := range catalog { result[i] = ModelInfo{ - Name: string(m.Name), - DisplayName: m.DisplayName, - Size: m.Size, - Downloaded: a.modelManager.IsModelDownloaded(string(m.Name)), - EnglishOnly: m.EnglishOnly, + Name: spec.Name, + DisplayName: spec.DisplayName, + Size: spec.Size, + SizeBytes: spec.SizeBytes, + Downloaded: a.modelManager != nil && a.modelManager.IsModelDownloaded(spec.Name), + EnglishOnly: spec.EnglishOnly, + Provider: spec.Provider, + Speed: spec.Speed, + Accuracy: spec.Accuracy, + UseCase: spec.UseCase, + Recommendation: spec.Recommendation, + Favorite: a.configManager != nil && a.configManager.IsFavoriteModel(spec.Name), } } - return result } -// SetModel changes the current model +// SetModel activates a downloaded catalog model. It never downloads. func (a *App) SetModel(model string) error { + if _, err := models.RequireModel(model); err != nil { + return err + } if a.configManager.Get().Provider == "local" && !a.modelManager.IsModelDownloaded(model) { return fmt.Errorf("model %q is not downloaded", model) } @@ -888,7 +910,9 @@ func (a *App) SetProvider(provider string) error { } if provider == "local" { a.localEngine.Open() - a.prewarmLocalEngine() + if a.modelManager != nil && a.modelManager.IsModelDownloaded(a.configManager.Get().Model) { + a.prewarmLocalEngine() + } } else { _ = a.localEngine.Close() } @@ -1127,36 +1151,76 @@ func (a *App) ReregisterHotkey() error { return nil } -// DownloadModel downloads a model +// DownloadModel starts downloading a model in the background. It validates the +// model and the single-download constraint synchronously, and never changes the +// active model. func (a *App) DownloadModel(model string) error { - go func() { - defer crashlog.LogPanic("model download") - err := a.modelManager.DownloadModel(model, func(downloaded, total int64) { - progress := float64(downloaded) / float64(total) * 100 - runtime.EventsEmit(a.ctx, "downloadProgress", map[string]interface{}{ - "model": model, - "downloaded": downloaded, - "total": total, - "progress": progress, - }) - }) - - if err != nil { - runtime.EventsEmit(a.ctx, "downloadError", map[string]interface{}{ - "model": model, - "error": err.Error(), - }) - } else { - runtime.EventsEmit(a.ctx, "downloadComplete", map[string]interface{}{ - "model": model, - }) - a.emitState() - } - }() + if a.downloader == nil { + return fmt.Errorf("model manager is not ready") + } + if err := a.downloader.Start(model); err != nil { + runtime.LogWarning(a.ctx, fmt.Sprintf("Model download rejected: model=%s error=%v", model, err)) + return err + } + runtime.LogInfo(a.ctx, fmt.Sprintf("Model download started: model=%s", model)) + return nil +} +// SetModelFavorite stars or unstars a catalog model in the library. +func (a *App) SetModelFavorite(model string, favorite bool) error { + if _, err := models.RequireModel(model); err != nil { + return err + } + if a.WaitForStartup() != nil || a.configManager == nil { + return fmt.Errorf("configuration is not ready") + } + return a.configManager.SetModelFavorite(model, favorite) +} + +// GetModelDownloadState returns the current or most recent download state. +func (a *App) GetModelDownloadState() models.DownloadState { + if a.WaitForStartup() != nil || a.downloader == nil { + return models.DownloadState{Status: models.DownloadIdle} + } + return a.downloader.State() +} + +// CancelModelDownload cancels the active download if it matches model. +func (a *App) CancelModelDownload(model string) error { + if a.downloader == nil { + return fmt.Errorf("model manager is not ready") + } + return a.downloader.Cancel(model) +} + +// DeleteModel removes a downloaded model that is neither active nor downloading. +func (a *App) DeleteModel(model string) error { + if a.downloader == nil || a.configManager == nil { + return fmt.Errorf("model manager is not ready") + } + if err := a.downloader.Delete(model, a.configManager.Get().Model); err != nil { + return err + } + runtime.LogInfo(a.ctx, fmt.Sprintf("Model deleted: model=%s", model)) + a.emitState() return nil } +func (a *App) onModelDownloadFinished(state models.DownloadState) { + if state.Status == models.DownloadFailed { + runtime.LogWarning(a.ctx, fmt.Sprintf("Model download failed: model=%s error=%s", state.Model, state.Error)) + } else { + runtime.LogInfo(a.ctx, fmt.Sprintf("Model download finished: model=%s status=%s", state.Model, state.Status)) + } + if state.Status == models.DownloadCompleted && a.configManager != nil { + config := a.configManager.Get() + if config.Provider == "local" && config.Model == state.Model { + a.prewarmLocalEngine() + } + } + a.emitState() +} + // IsModelDownloaded checks if a model is downloaded func (a *App) IsModelDownloaded(model string) bool { return a.modelManager.IsModelDownloaded(model) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f79b3ac..4017bf0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,13 +12,25 @@ "react-dom": "^18.2.0" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^14.3.1", + "@types/node": "^18.19.130", "@types/react": "^18.0.17", "@types/react-dom": "^18.0.6", "@vitejs/plugin-react": "^2.0.1", + "jsdom": "^22.1.0", "typescript": "^4.6.4", - "vite": "^3.0.7" + "vite": "^3.0.7", + "vitest": "^0.34.6" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -318,6 +330,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -400,6 +422,19 @@ "node": ">=12" } }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -450,6 +485,133 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@sinclair/typebox": { + "version": "0.27.12", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.12.tgz", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "9.3.4", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz", + "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.1.3", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", + "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "deep-equal": "^2.0.5" + } + }, + "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/react": { + "version": "14.3.1", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.3.1.tgz", + "integrity": "sha512-H99XjUhWQw0lTgyMN05W3xQG1Nh4lq574D8keFf1dDoNTJgp66VbJozRaczoF+wsiaPJNt/TcnfpLGufGxSrZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5", + "@testing-library/dom": "^9.0.0", + "@types/react-dom": "^18.0.0" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, + "node_modules/@tootallnate/once": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", + "integrity": "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "4.3.20", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz", + "integrity": "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai-subset": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@types/chai-subset/-/chai-subset-1.3.6.tgz", + "integrity": "sha512-m8lERkkQj+uek18hXOZuec3W/fCRTrU4hrnXjH3qhHy96ytuPaPiWGgu7sJb7tZxZonO75vYAjCvpe/e4VUwRw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/chai": "<5.2.0" + } + }, + "node_modules/@types/node": { + "version": "18.19.130", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", + "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, "node_modules/@types/prop-types": { "version": "15.7.15", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", @@ -500,6 +662,292 @@ "vite": "^3.0.0" } }, + "node_modules/@vitest/expect": { + "version": "0.34.6", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-0.34.6.tgz", + "integrity": "sha512-QUzKpUQRc1qC7qdGo7rMK3AkETI7w18gTCUrsNnyjjJKYiuUB9+TQK3QnR1unhCnWRC0AbKv2omLGQDF/mIjOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "0.34.6", + "@vitest/utils": "0.34.6", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "0.34.6", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-0.34.6.tgz", + "integrity": "sha512-1CUQgtJSLF47NnhN+F9X2ycxUP0kLHQ/JWvNHbeBfwW8CzEGgeskzNnHDyv1ieKTltuR6sdIHV+nmR6kPxQqzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "0.34.6", + "p-limit": "^4.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "0.34.6", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-0.34.6.tgz", + "integrity": "sha512-B3OZqYn6k4VaN011D+ve+AA4whM4QkcwcrwaKwAbyyvS/NB1hCWjFIBQxAQQSQir9/RtyAAGuq+4RJmbn2dH4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.1", + "pathe": "^1.1.1", + "pretty-format": "^29.5.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@vitest/snapshot/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@vitest/snapshot/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@vitest/snapshot/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/spy": { + "version": "0.34.6", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-0.34.6.tgz", + "integrity": "sha512-xaCvneSaeBw/cz8ySmF7ZwGvL0lBjfvqc1LpQ/vcdHEvpLn3Ff1vAvjw+CoGn0802l++5L/pxb7whwcWAw+DUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^2.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "0.34.6", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-0.34.6.tgz", + "integrity": "sha512-IG5aDD8S6zlvloDsnzHw0Ut5xczlF+kv2BOTo+iXfPr54Yhi5qbVOgGB1hZaVq4iJ4C/MZ2J0y15IlsV/ZcI0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "diff-sequences": "^29.4.3", + "loupe": "^2.3.6", + "pretty-format": "^29.5.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@vitest/utils/node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@vitest/utils/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/abab": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/abab/-/abab-2.0.6.tgz", + "integrity": "sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==", + "deprecated": "Use your platform's native atob() and btoa() methods instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.27", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz", @@ -547,40 +995,224 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/caniuse-lite": { - "version": "1.0.30001791", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", - "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=8" + } }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", + "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-3.0.0.tgz", + "integrity": "sha512-N4u2ABATi3Qplzf0hWbVCdjenim8F3ojEXpBDF5hBpjzW182MjNGLqfmQ0SkSPeQ+V86ZXgeH8aXj6kayd4jgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "rrweb-cssom": "^0.6.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-4.0.0.tgz", + "integrity": "sha512-/mMTei/JXPqvFqQtfyTowxmJVwr2PVAeCcDxyFf6LhoOu/09TX2OX3kb2wzi4DMXcfj4OItwDOnhl5oziPnT6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "abab": "^2.0.6", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^12.0.0" + }, + "engines": { + "node": ">=14" + } }, "node_modules/debug": { "version": "4.4.3", @@ -600,6 +1232,151 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.4.tgz", + "integrity": "sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-equal": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.5", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.2", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/domexception": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", + "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "license": "MIT", + "dependencies": { + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.350", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.350.tgz", @@ -607,6 +1384,29 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-errors": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", @@ -617,6 +1417,56 @@ "node": ">= 0.4" } }, + "node_modules/es-get-iterator": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", + "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", + "has-symbols": "^1.0.3", + "is-arguments": "^1.1.1", + "is-map": "^2.0.2", + "is-set": "^2.0.2", + "is-string": "^1.0.7", + "isarray": "^2.0.5", + "stop-iteration-iterator": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.15.18", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.15.18.tgz", @@ -1005,20 +1855,53 @@ "node": ">=6" } }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], + "dependencies": { + "is-callable": "^1.2.7" + }, "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } }, "node_modules/function-bind": { "version": "1.1.2", @@ -1030,6 +1913,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -1040,10 +1933,137 @@ "node": ">=6.9.0" } }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "dev": true, "license": "MIT", "dependencies": { @@ -1053,6 +2073,167 @@ "node": ">= 0.4" } }, + "node_modules/html-encoding-sniffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/http-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tootallnate/once": "2", + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-core-module": { "version": "2.16.2", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", @@ -1069,12 +2250,229 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, + "node_modules/jsdom": { + "version": "22.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-22.1.0.tgz", + "integrity": "sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "abab": "^2.0.6", + "cssstyle": "^3.0.0", + "data-urls": "^4.0.0", + "decimal.js": "^10.4.3", + "domexception": "^4.0.0", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.4", + "parse5": "^7.1.2", + "rrweb-cssom": "^0.6.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^4.1.2", + "w3c-xmlserializer": "^4.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^12.0.1", + "ws": "^8.13.0", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -1101,6 +2499,19 @@ "node": ">=6" } }, + "node_modules/local-pkg": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.4.3.tgz", + "integrity": "sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -1113,6 +2524,16 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-func-name": "^2.0.1" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -1123,19 +2544,92 @@ "yallist": "^3.0.2" } }, - "node_modules/magic-string": { - "version": "0.26.7", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.26.7.tgz", - "integrity": "sha512-hX9XH3ziStPoPhJxLq1syWuZMxbDvGNbVchfrdCtanC7D13888bMFow61x8axrx+GfHLtVeAx2kxL7tTGRl+Ow==", + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.26.7", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.26.7.tgz", + "integrity": "sha512-hX9XH3ziStPoPhJxLq1syWuZMxbDvGNbVchfrdCtanC7D13888bMFow61x8axrx+GfHLtVeAx2kxL7tTGRl+Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "sourcemap-codec": "^1.4.8" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", "dev": true, "license": "MIT", "dependencies": { - "sourcemap-codec": "^1.4.8" - }, - "engines": { - "node": ">=12" + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" } }, + "node_modules/mlly/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -1169,6 +2663,103 @@ "dev": true, "license": "MIT" }, + "node_modules/nwsapi": { + "version": "2.2.28", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.28.tgz", + "integrity": "sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -1176,6 +2767,23 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1183,6 +2791,35 @@ "dev": true, "license": "ISC" }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pkg-types/node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "8.5.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", @@ -1212,6 +2849,64 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==", + "dev": true, + "license": "MIT" + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -1237,6 +2932,13 @@ "react": "^18.3.1" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", @@ -1247,6 +2949,48 @@ "node": ">=0.10.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "set-function-name": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "dev": true, + "license": "MIT" + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -1285,6 +3029,51 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrweb-cssom": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz", + "integrity": "sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -1294,15 +3083,132 @@ "loose-envify": "^1.1.0" } }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } + "license": "ISC" }, "node_modules/source-map-js": { "version": "1.2.1", @@ -1322,6 +3228,73 @@ "dev": true, "license": "MIT" }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-literal": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-1.3.0.tgz", + "integrity": "sha512-PugKzOsyXpArk0yWmUwqOZecSO0GH0bPoctLcqNDH9J04pVW3lflYE0ujElBGTloevcxF5MofAOZ7C5l2b+wLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.10.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -1335,6 +3308,79 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.7.0.tgz", + "integrity": "sha512-zSYNUlYSMhJ6Zdou4cJwo/p7w5nmAH17GRfU/ui3ctvjXFErXXkruT4MWW6poDeXgCaIBlGLrfU6TbTXxyGMww==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tough-cookie": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.4.tgz", + "integrity": "sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tr46": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-4.1.1.tgz", + "integrity": "sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/typescript": { "version": "4.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", @@ -1349,6 +3395,30 @@ "node": ">=4.2.0" } }, + "node_modules/ufo": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -1380,6 +3450,17 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/vite": { "version": "3.2.11", "resolved": "https://registry.npmjs.org/vite/-/vite-3.2.11.tgz", @@ -1430,12 +3511,315 @@ } } }, + "node_modules/vite-node": { + "version": "0.34.6", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-0.34.6.tgz", + "integrity": "sha512-nlBMJ9x6n7/Amaz6F3zJ97EBwR2FkzhBRxF5e+jE6LA3yi6Wtc2lyTij1OnDMIr34v5g/tVQtsVAzhT0jc5ygA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "mlly": "^1.4.0", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": ">=v14.18.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "0.34.6", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-0.34.6.tgz", + "integrity": "sha512-+5CALsOvbNKnS+ZHMXtuUC7nL8/7F1F2DnHGjSsszX8zCjWSSviphCb/NuS9Nzf4Q03KyyDRBAXhF/8lffME4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^4.3.5", + "@types/chai-subset": "^1.3.3", + "@types/node": "*", + "@vitest/expect": "0.34.6", + "@vitest/runner": "0.34.6", + "@vitest/snapshot": "0.34.6", + "@vitest/spy": "0.34.6", + "@vitest/utils": "0.34.6", + "acorn": "^8.9.0", + "acorn-walk": "^8.2.0", + "cac": "^6.7.14", + "chai": "^4.3.10", + "debug": "^4.3.4", + "local-pkg": "^0.4.3", + "magic-string": "^0.30.1", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.3.3", + "strip-literal": "^1.0.1", + "tinybench": "^2.5.0", + "tinypool": "^0.7.0", + "vite": "^3.1.0 || ^4.0.0 || ^5.0.0-0", + "vite-node": "0.34.6", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": ">=v14.18.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@vitest/browser": "*", + "@vitest/ui": "*", + "happy-dom": "*", + "jsdom": "*", + "playwright": "*", + "safaridriver": "*", + "webdriverio": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "playwright": { + "optional": true + }, + "safaridriver": { + "optional": true + }, + "webdriverio": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", + "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-url": { + "version": "12.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-12.0.1.tgz", + "integrity": "sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^4.1.1", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-bigint": "^1.1.0", + "is-boolean-object": "^1.2.1", + "is-number-object": "^1.1.1", + "is-string": "^1.1.1", + "is-symbol": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.24", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.24.tgz", + "integrity": "sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/frontend/package.json b/frontend/package.json index 50bf032..2b95325 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,17 +6,23 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run" }, "dependencies": { "react": "^18.2.0", "react-dom": "^18.2.0" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^14.3.1", + "@types/node": "^18.19.130", "@types/react": "^18.0.17", "@types/react-dom": "^18.0.6", "@vitejs/plugin-react": "^2.0.1", + "jsdom": "^22.1.0", "typescript": "^4.6.4", - "vite": "^3.0.7" + "vite": "^3.0.7", + "vitest": "^0.34.6" } } diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index ff76a6d..68cb67f 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -2a4ee46ca604023a8c5ca977c49538ff \ No newline at end of file +47d3ef1edc2c089ae7bbe1b8e77c9c89 \ No newline at end of file diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx new file mode 100644 index 0000000..d657819 --- /dev/null +++ b/frontend/src/App.test.tsx @@ -0,0 +1,144 @@ +import { StrictMode } from 'react'; +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as Api from '../wailsjs/go/main/App'; +import App from './App'; +import { EventsEmit, listenerCount, resetListeners } from './test/runtimeMock'; +import { MODELS, downloadState } from './test/fixtures'; +import { IDLE_DOWNLOAD } from './modelTypes'; + +vi.mock('../wailsjs/runtime/runtime', async () => await import('./test/runtimeMock')); +vi.mock('../wailsjs/go/main/App', async (importOriginal) => { + const actual = await importOriginal>(); + return Object.fromEntries(Object.keys(actual).map((key) => [key, vi.fn(() => Promise.resolve())])); +}); + +const api = Api as unknown as Record>; + +function appState(overrides: Record = {}) { + return { + state: 'ready', recordingTime: 0, lastTranscript: '', error: '', + currentModel: 'base.en', currentProvider: 'local', modelReady: true, hotkeyEnabled: true, + ...overrides, + }; +} + +function setup({ state = appState(), models = MODELS, download = IDLE_DOWNLOAD } = {}) { + api.IsOnboardingCompleted.mockResolvedValue(true); + api.GetPlatform.mockResolvedValue('linux'); + api.GetState.mockResolvedValue(state); + api.GetModels.mockResolvedValue(models); + api.GetConfig.mockResolvedValue({ provider: state.currentProvider, model: state.currentModel, autoPaste: true }); + api.GetHistory.mockResolvedValue([]); + api.GetAudioInputDevices.mockResolvedValue([]); + api.GetStats.mockResolvedValue({ averageWPM: 0, wordsThisWeek: 0, recordingsThisWeek: 0, timeSavedThisWeek: 0, totalRecordings: 0, totalWords: 0 }); + api.GetRecordingHotkey.mockResolvedValue('rightalt'); + api.GetCancelHotkey.mockResolvedValue('escape'); + api.GetModelDownloadState.mockResolvedValue(download); + return render(); +} + +const nav = (name: string) => within(document.querySelector('.sidebar-nav') as HTMLElement).getByRole('button', { name: new RegExp(`^${name}`) }); + +describe('App model integration', () => { + beforeEach(() => { + resetListeners(); + Object.values(api).forEach((fn) => { + fn.mockReset(); + fn.mockImplementation(() => Promise.resolve()); + }); + }); + + it('adds a Models page to the sidebar and keeps a single download listener', async () => { + setup(); + fireEvent.click(await screen.findByRole('button', { name: /^Models/ })); + expect(document.querySelector('.page-title')).toHaveTextContent('Models library'); + expect(await screen.findByTestId('model-row-large-v3')).toBeInTheDocument(); + // The toolbar is rendered into the page header. + expect(within(document.querySelector('.main-header') as HTMLElement).getByRole('button', { name: 'Filter models' })).toBeInTheDocument(); + expect(listenerCount('modelDownloadState')).toBe(1); + expect(listenerCount('stateChanged')).toBe(1); + expect(listenerCount('downloadComplete')).toBe(0); + }); + + it('Settings summarises the active model and links into the library', async () => { + setup(); + fireEvent.click(await screen.findByRole('button', { name: /^Settings/ })); + const row = (await screen.findByText('Whisper Base (English)')).closest('.setting-row') as HTMLElement; + expect(row).toHaveTextContent('148 MB · English only · Downloaded'); + expect(screen.queryByRole('combobox', { name: /model/i })).toBeNull(); + fireEvent.click(within(row).getByRole('button', { name: 'Browse Models' })); + expect(await screen.findByRole('dialog', { name: 'Whisper Base (English) details' })).toBeInTheDocument(); + }); + + it('Settings hides local model controls when OpenAI is active', async () => { + setup({ state: appState({ currentProvider: 'openai' }) }); + fireEvent.click(await screen.findByRole('button', { name: /^Settings/ })); + expect(await screen.findByText(/OpenAI transcribes with its hosted Whisper model/)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Browse Models' })).toBeNull(); + }); + + it('Home routes a missing model to the library or downloads that exact model', async () => { + setup({ state: appState({ currentModel: 'small', modelReady: false }) }); + const prompt = (await screen.findByText('Model "Whisper Small" needs to be downloaded')).closest('.download-prompt') as HTMLElement; + fireEvent.click(within(prompt).getByRole('button', { name: 'Download (488 MB)' })); + await waitFor(() => expect(api.DownloadModel).toHaveBeenCalledWith('small')); + + fireEvent.click(within(prompt).getByRole('button', { name: 'Browse models' })); + expect(await screen.findByRole('dialog', { name: 'Whisper Small details' })).toBeInTheDocument(); + }); + + it('Home shows download start errors inline', async () => { + setup({ state: appState({ currentModel: 'small', modelReady: false }) }); + api.DownloadModel.mockRejectedValue('another model is already downloading: tiny'); + fireEvent.click(await screen.findByRole('button', { name: 'Download (488 MB)' })); + expect(await screen.findByText('another model is already downloading: tiny')).toBeInTheDocument(); + }); + + it('Home progress is labelled by the downloading model, not the active one', async () => { + setup({ download: downloadState({ model: 'large-v3', status: 'downloading', downloaded: 1e9, total: 3095033483, progress: 32.3 }) }); + expect(await screen.findByText('Downloading Whisper Large v3')).toBeInTheDocument(); + expect(screen.getByText('1.00 GB of 3.10 GB')).toBeInTheDocument(); + expect(nav('Models')).toHaveTextContent('32%'); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(api.CancelModelDownload).toHaveBeenCalledWith('large-v3')); + }); + + it('download completion refreshes availability without activating the model', async () => { + setup(); + await screen.findByText('Get started'); + const before = api.GetModels.mock.calls.length; + act(() => EventsEmit('modelDownloadState', downloadState({ model: 'small', status: 'downloading', total: 10 }))); + act(() => EventsEmit('modelDownloadState', downloadState({ model: 'small', status: 'completed', downloaded: 10, total: 10, progress: 100 }))); + await waitFor(() => expect(api.GetModels.mock.calls.length).toBeGreaterThan(before)); + expect(api.SetModel).not.toHaveBeenCalled(); + }); + + it('activates a downloaded model from the library with one action', async () => { + setup(); + fireEvent.click(await screen.findByRole('button', { name: /^Models/ })); + fireEvent.click(await screen.findByTestId('model-row-base')); + fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Use this model' })); + await waitFor(() => expect(api.SetModel).toHaveBeenCalledWith('base')); + }); + + it('shows backend delete rejections inline', async () => { + setup(); + api.DeleteModel.mockRejectedValue('the active model cannot be deleted'); + fireEvent.click(await screen.findByRole('button', { name: /^Models/ })); + fireEvent.click(await screen.findByTestId('model-row-base')); + const panel = within(screen.getByRole('dialog')); + fireEvent.click(panel.getByRole('button', { name: 'Delete from this computer' })); + fireEvent.click(panel.getByRole('button', { name: 'Confirm delete' })); + expect(await panel.findByRole('alert')).toHaveTextContent('the active model cannot be deleted'); + }); + + it('persists favorites through the backend and refreshes models', async () => { + setup(); + fireEvent.click(await screen.findByRole('button', { name: /^Models/ })); + const before = api.GetModels.mock.calls.length; + fireEvent.click(within(await screen.findByTestId('model-row-small')).getByRole('button', { name: 'Add Whisper Small to favorites' })); + await waitFor(() => expect(api.SetModelFavorite).toHaveBeenCalledWith('small', true)); + await waitFor(() => expect(api.GetModels.mock.calls.length).toBeGreaterThan(before)); + }); +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b4157e9..1e0d1be 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,6 +2,9 @@ import { useState, useEffect, useCallback, useRef } from 'react'; import './App.css'; import { RecordingOverlay } from './RecordingOverlay'; import { Onboarding } from './Onboarding'; +import { ModelsLibrary } from './ModelsLibrary'; +import { useModelDownload } from './useModelDownload'; +import { ModelInfo, errorMessage, formatBytes, fullModelName, isDownloadActive, isTerminal } from './modelTypes'; import packageJson from '../package.json'; // Sound playback is now handled natively in Go (internal/sounds) import { @@ -13,6 +16,9 @@ import { SetProvider, SetOpenAIKey, DownloadModel, + CancelModelDownload, + DeleteModel, + SetModelFavorite, GetConfig, SetAutoPaste, SetSoundEnabled, @@ -49,14 +55,6 @@ interface AppState { hotkeyEnabled: boolean; } -interface ModelInfo { - name: string; - displayName: string; - size: string; - downloaded: boolean; - englishOnly: boolean; -} - interface Config { provider: string; model: string; @@ -75,13 +73,6 @@ interface HistoryItem { hasAudio: boolean; } -interface DownloadProgress { - model: string; - downloaded: number; - total: number; - progress: number; -} - interface AudioInputDevice { name: string; isDefault: boolean; @@ -96,7 +87,7 @@ interface UsageStats { totalWords: number; } -type Page = 'home' | 'settings' | 'history'; +type Page = 'home' | 'models' | 'settings' | 'history'; type AccessibilityRecoveryState = 'checking' | 'needed' | 'recovering' | 'error' | 'complete' | 'not-needed'; function App() { @@ -120,7 +111,10 @@ function App() { const [selectedHistory, setSelectedHistory] = useState(null); const [currentPage, setCurrentPage] = useState('home'); const [apiKey, setApiKey] = useState(''); - const [downloadProgress, setDownloadProgress] = useState(null); + const downloadState = useModelDownload(); + const [focusModel, setFocusModel] = useState(null); + const [headerActions, setHeaderActions] = useState(null); + const [homeDownloadError, setHomeDownloadError] = useState(''); const [isPlaying, setIsPlaying] = useState(false); const [audioContext, setAudioContext] = useState(null); const [audioSource, setAudioSource] = useState(null); @@ -279,24 +273,23 @@ function App() { // Refresh stats when history changes (new transcription) GetStats().then((s: UsageStats) => setStats(s)); })); - unsubscribers.push(EventsOn('downloadProgress', (progress: DownloadProgress) => setDownloadProgress(progress))); - unsubscribers.push(EventsOn('downloadComplete', async (data: { model: string }) => { - setDownloadProgress(null); - try { - await SetModel(data.model); - } catch (err) { - LogError(`[frontend] SetModel after download failed: ${err}`); - } - GetModels().then((models: ModelInfo[]) => setModels(models)); - GetState().then((state: AppState) => setAppState(state)); - })); - unsubscribers.push(EventsOn('downloadError', (data: { model: string; error: string }) => { - setDownloadProgress(null); - alert(`Download failed: ${data.error}`); - })); return () => unsubscribers.forEach((unsubscribe) => unsubscribe()); }, []); + const refreshModels = useCallback(() => GetModels().then((m: ModelInfo[]) => setModels(m)), []); + + const refreshAppState = useCallback(() => { + GetState().then((s: AppState) => setAppState(s)); + }, []); + + // Download completion only refreshes availability; activation is always an + // explicit user action (or Onboarding's own selected model). + useEffect(() => { + if (!isTerminal(downloadState)) return; + refreshModels(); + refreshAppState(); + }, [downloadState.status, downloadState.model, refreshModels, refreshAppState]); + useEffect(() => { let interval: number | undefined; if (appState.state === 'recording') { @@ -431,26 +424,56 @@ function App() { } }, [audioContext, audioSource]); - const handleModelChange = useCallback(async (model: string) => { - const modelInfo = models.find(m => m.name === model); - if (modelInfo?.downloaded) { - await SetModel(model); - GetState().then((s: AppState) => setAppState(s)); - } else { + const handleDownloadModel = useCallback(async (model: string) => { + await DownloadModel(model); + }, []); + + const handleCancelDownload = useCallback(async (model: string) => { + await CancelModelDownload(model); + }, []); + + const handleUseModel = useCallback(async (model: string) => { + await SetModel(model); + refreshModels(); + refreshAppState(); + }, [refreshModels, refreshAppState]); + + const handleDeleteModel = useCallback(async (model: string) => { + try { + await DeleteModel(model); + } finally { + refreshModels(); + refreshAppState(); + } + }, [refreshModels, refreshAppState]); + + const handleToggleFavorite = useCallback(async (model: string, favorite: boolean) => { + try { + await SetModelFavorite(model, favorite); + } finally { + await refreshModels(); + } + }, [refreshModels]); + + const openModelsLibrary = useCallback((focus?: string) => { + setFocusModel(focus ?? null); + setCurrentPage('models'); + }, []); + + const handleHomeDownload = useCallback(async (model: string) => { + setHomeDownloadError(''); + try { await DownloadModel(model); + } catch (err) { + setHomeDownloadError(errorMessage(err)); } - GetModels().then((m: ModelInfo[]) => setModels(m)); - }, [models]); + }, []); const handleProviderChange = useCallback(async (provider: string) => { await SetProvider(provider); GetState().then((s: AppState) => setAppState(s)); }, []); - const handleDownloadModel = useCallback(async (model: string) => { - await DownloadModel(model); - }, []); - const handleSaveApiKey = useCallback(async () => { await SetOpenAIKey(apiKey); GetState().then((s: AppState) => setAppState(s)); @@ -649,6 +672,10 @@ function App() { const currentModel = models.find(m => m.name === appState.currentModel); const needsDownload = appState.currentProvider === 'local' && currentModel && !currentModel.downloaded; + const downloadActive = isDownloadActive(downloadState); + const downloadingModel = models.find(m => m.name === downloadState.model); + const currentModelFailed = downloadState.model === appState.currentModel && downloadState.status === 'failed'; + const currentModelCancelled = downloadState.model === appState.currentModel && downloadState.status === 'cancelled'; const handleOnboardingComplete = useCallback(() => { setShowOnboarding(false); @@ -757,6 +784,19 @@ function App() { Home + + +

+ {currentModelFailed + ? `Downloading "${currentModel ? fullModelName(currentModel) : appState.currentModel}" failed.` + : currentModelCancelled + ? `Download of "${currentModel ? fullModelName(currentModel) : appState.currentModel}" was cancelled.` + : `Model "${currentModel ? fullModelName(currentModel) : appState.currentModel}" needs to be downloaded`} +

+ {currentModelFailed &&

{downloadState.error}

} + {homeDownloadError &&

{homeDownloadError}

} +
+ + +
)} - {downloadProgress && ( + {downloadActive && (
@@ -838,12 +902,28 @@ function App() {
-

Downloading {currentModel?.displayName || downloadProgress.model}

-
{currentModel?.size}
+

+ {downloadState.status === 'cancelling' ? 'Cancelling' : 'Downloading'} {downloadingModel ? fullModelName(downloadingModel) : downloadState.model} +

+
+ {formatBytes(downloadState.downloaded)} of {formatBytes(downloadState.total || downloadingModel?.sizeBytes || 0)} +
-
+
+
+ {downloadState.progress.toFixed(0)}% +
+ +
- {downloadProgress.progress.toFixed(0)}%
)} @@ -879,7 +959,21 @@ function App() {
Settings - Configure model, audio device, and more + Configure provider, audio device, hotkeys, and more +
+
+ +
openModelsLibrary()}> +
+ + + + + +
+
+ Browse models + Compare, download, and switch transcription models
@@ -940,6 +1034,22 @@ function App() { )} + {currentPage === 'models' && ( + + )} + {currentPage === 'settings' && (
@@ -967,22 +1077,36 @@ function App() {
-
-
- -

Larger models are more accurate but slower

+ {appState.currentProvider === 'local' ? ( +
+
+ +

Larger models are more accurate but slower

+
+
+
+ {currentModel ? fullModelName(currentModel) : appState.currentModel} + {currentModel + ? `${currentModel.size} · ${currentModel.englishOnly ? 'English only' : 'Multilingual'} · ${ + downloadActive && downloadState.model === currentModel.name + ? `Downloading ${downloadState.progress.toFixed(0)}%` + : currentModel.downloaded ? 'Downloaded' : 'Not downloaded' + }` + : ''} +
+ +
- -
+ ) : ( +
+
+ +

OpenAI transcribes with its hosted Whisper model, so local models are not used.

+
+
+ )} {appState.currentProvider === 'openai' && (
diff --git a/frontend/src/ModelsLibrary.css b/frontend/src/ModelsLibrary.css new file mode 100644 index 0000000..5584809 --- /dev/null +++ b/frontend/src/ModelsLibrary.css @@ -0,0 +1,959 @@ +.models-page { + position: relative; + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} + +/* Header toolbar (rendered into the page header) */ +.ml-toolbar { + display: flex; + align-items: center; + gap: 8px; + -webkit-app-region: no-drag; +} + +.ml-icon-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: 7px; + background: transparent; + color: var(--text-secondary); + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +.ml-icon-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.ml-icon-btn:focus-visible, +.ml-sort:focus-visible, +.ml-name-cell:focus-visible, +.ml-status-icon.btn:focus-visible, +.ml-menu-item:focus-visible, +.ml-cta:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.ml-icon-btn.round { + width: 30px; + height: 30px; + border-radius: 50%; + background: var(--bg-card); + border: 1px solid var(--border); +} + +.ml-icon-btn.round.on { + color: var(--accent); + border-color: rgba(0, 255, 78, 0.35); +} + +.ml-icon-btn.tiny { + width: 20px; + height: 20px; +} + +.ml-icon-btn.tiny svg { + width: 12px; + height: 12px; +} + +.ml-chip { + display: inline-flex; + align-items: center; + gap: 4px; + height: 26px; + padding: 0 4px 0 10px; + border-radius: 13px; + background: var(--bg-card); + border: 1px solid var(--border); + font-size: 12px; + color: var(--text-primary); + white-space: nowrap; +} + +.ml-chip button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border: none; + border-radius: 50%; + background: var(--bg-hover); + color: var(--text-secondary); + cursor: pointer; + padding: 0; +} + +.ml-chip button svg { + width: 10px; + height: 10px; +} + +.ml-search { + display: flex; + align-items: center; + gap: 6px; + height: 30px; + padding: 0 4px 0 10px; + border-radius: 15px; + background: var(--bg-card); + border: 1px solid var(--border); + color: var(--text-secondary); +} + +.ml-search:focus-within { + border-color: rgba(0, 255, 78, 0.5); +} + +.ml-search > svg { + width: 14px; + height: 14px; + flex-shrink: 0; +} + +.ml-search input { + width: 150px; + border: none; + outline: none; + background: transparent; + color: var(--text-primary); + font-size: 13px; +} + +.ml-search input::-webkit-search-cancel-button { + display: none; +} + +.ml-menu-anchor { + position: relative; +} + +.ml-menu { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 30; + min-width: 210px; + padding: 6px; + border-radius: 12px; + background: #242426; + border: 1px solid var(--border); + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45); +} + +.ml-menu-item { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 8px 10px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--text-primary); + font-size: 13px; + text-align: left; + cursor: pointer; +} + +.ml-menu-item:hover { + background: var(--bg-hover); +} + +.ml-check { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-radius: 50%; + border: 1.5px solid var(--text-muted); + flex-shrink: 0; +} + +.ml-check.on { + background: var(--accent); + border-color: var(--accent); + color: #000; +} + +.ml-check svg { + width: 10px; + height: 10px; + stroke-width: 3; +} + +.ml-menu-label { + flex: 1; +} + +.ml-menu-count { + font-size: 12px; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + +.ml-provider-note { + margin: 12px 20px 0; + padding: 9px 12px; + border-radius: 10px; + background: rgba(255, 214, 10, 0.08); + border: 1px solid rgba(255, 214, 10, 0.25); + color: var(--warning); + font-size: 12px; + line-height: 1.45; +} + +/* Table */ +.ml-table-wrap { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 4px 12px 24px; +} + +.ml-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + table-layout: fixed; +} + +.ml-table thead th { + position: sticky; + top: 0; + z-index: 2; + height: 36px; + padding: 0 8px; + background: var(--bg-dark); + font-weight: 500; + text-align: left; +} + +.ml-table .col-star { + width: 36px; + padding-left: 4px; + padding-right: 0; +} + +.ml-table .col-rating { + width: 92px; +} + +.ml-table .col-status { + width: 124px; +} + +.ml-table thead .col-status { + text-align: right; +} + +.ml-sort { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 4px; + margin-left: -4px; + border: none; + border-radius: 5px; + background: transparent; + color: var(--text-muted); + font-size: 12px; + cursor: pointer; +} + +.ml-sort:hover, +.ml-sort.active { + color: var(--text-secondary); +} + +.ml-sort-arrow { + display: inline-block; + width: 8px; + font-size: 11px; + line-height: 1; + transform: translateY(2px); +} + +.ml-sort-arrow.down { + transform: rotate(180deg) translateY(1px); +} + +.ml-table tbody tr { + cursor: pointer; +} + +.ml-table tbody td { + height: 40px; + padding: 0 8px; + border-top: 1px solid transparent; +} + +.ml-table tbody td:first-child { + border-radius: 10px 0 0 10px; +} + +.ml-table tbody td:last-child { + border-radius: 0 10px 10px 0; +} + +.ml-table tbody tr:hover td { + background: rgba(255, 255, 255, 0.04); +} + +.ml-table tbody tr.selected td { + background: var(--bg-card); +} + +.ml-star { + width: 26px; + height: 26px; + color: var(--text-muted); +} + +.ml-star.on { + color: var(--warning); +} + +.ml-name-cell { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + border-radius: 6px; + outline: none; +} + +.ml-name { + font-size: 14px; + font-weight: 500; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ml-table tbody tr.active .ml-name { + color: var(--accent); +} + +.ml-tag { + flex-shrink: 0; + padding: 1px 5px; + border-radius: 4px; + background: var(--bg-hover); + color: var(--text-secondary); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.3px; + text-transform: uppercase; +} + +.ml-tag.accent { + background: rgba(0, 255, 78, 0.12); + color: var(--accent); +} + +.ml-logo { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 22px; + height: 22px; + border-radius: 6px; +} + +.ml-logo svg { + width: 14px; + height: 14px; +} + +.ml-logo-openai { + background: #f4f4f5; + color: #0d0d0d; +} + +.ml-segments { + display: inline-flex; + gap: 3px; +} + +.ml-segments span { + width: 11px; + height: 3px; + border-radius: 2px; + background: var(--bg-active); +} + +.ml-segments span.on { + background: var(--text-secondary); +} + +.ml-table tbody tr.selected .ml-segments span.on, +.ml-table tbody tr:hover .ml-segments span.on { + background: var(--text-primary); +} + +.ml-status-cell { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; +} + +.ml-size { + font-size: 12px; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.ml-active-pill { + font-size: 11px; + font-weight: 600; + color: var(--accent); +} + +.ml-status-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + flex-shrink: 0; + color: var(--text-secondary); +} + +.ml-status-icon svg { + width: 18px; + height: 18px; +} + +.ml-status-icon.btn { + padding: 0; + border: none; + border-radius: 50%; + background: transparent; + cursor: pointer; +} + +.ml-status-icon.btn:hover:not(:disabled) { + color: var(--accent); +} + +.ml-status-icon.btn:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.ml-status-icon.failed { + color: var(--danger); +} + +.ml-status-icon.done { + width: 20px; + height: 20px; + border-radius: 50%; + background: var(--bg-hover); +} + +.ml-status-icon.done svg { + width: 12px; + height: 12px; + stroke-width: 3; +} + +.ml-status-icon.done.active { + background: var(--accent); + color: #000; +} + +.ml-status-icon.dim { + opacity: 0.5; +} + +.ml-ring circle { + fill: none; + stroke-width: 2.5; +} + +.ml-ring .track { + stroke: var(--bg-active); +} + +.ml-ring .fill { + stroke: var(--accent); + stroke-linecap: round; + transition: stroke-dasharray 0.3s ease; +} + +.ml-empty { + font-size: 13px; + color: var(--text-muted); + padding: 32px 0; + text-align: center; +} + +/* Detail panel */ +.ml-detail { + position: absolute; + top: 0; + right: 0; + bottom: 0; + z-index: 20; + width: min(420px, 62%); + display: flex; + flex-direction: column; + background: #1f1f21; + border-left: 1px solid var(--border); + box-shadow: -16px 0 32px rgba(0, 0, 0, 0.35); + animation: ml-slide-in 0.18s ease-out; +} + +@keyframes ml-slide-in { + from { + transform: translateX(24px); + opacity: 0; + } + to { + transform: none; + opacity: 1; + } +} + +.ml-detail-header { + display: flex; + align-items: center; + gap: 8px; + padding: 12px 12px 8px; +} + +.ml-detail-title { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; +} + +.ml-detail-title h2 { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ml-detail-body { + flex: 1; + overflow-y: auto; + padding: 4px 20px 16px; +} + +.ml-recommend { + display: inline-block; + margin-bottom: 8px; + padding: 2px 8px; + border-radius: 999px; + background: rgba(0, 255, 78, 0.12); + color: var(--accent); + font-size: 11px; + font-weight: 600; +} + +.ml-detail-desc { + font-size: 13px; + line-height: 1.5; + color: var(--text-secondary); + margin-bottom: 14px; +} + +.ml-detail-card { + margin: 0; + padding: 6px 16px; + border-radius: 12px; + background: var(--bg-card); +} + +.ml-detail-card > div { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 34px; +} + +.ml-detail-card dt { + font-size: 13px; + color: var(--text-secondary); +} + +.ml-detail-card dd { + margin: 0; + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-primary); + text-align: right; +} + +.ml-detail-card .ml-segments span { + width: 18px; + height: 4px; +} + +.ml-detail-card .ml-segments span.on { + background: var(--text-primary); +} + +.ml-rating-text { + min-width: 56px; + font-size: 12px; + color: var(--text-secondary); +} + +.ml-specs { + gap: 6px; +} + +.ml-spec { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 6px; +} + +.ml-spec.yellow { + background: rgba(255, 214, 10, 0.15); + color: var(--warning); +} + +.ml-spec.purple { + background: rgba(191, 90, 242, 0.18); + color: #d29bf5; +} + +.ml-spec.blue { + background: rgba(10, 132, 255, 0.18); + color: #64aaff; +} + +.ml-spec.green { + background: rgba(0, 255, 78, 0.12); + color: var(--accent); +} + +.ml-error { + margin-top: 12px; + font-size: 12px; + line-height: 1.45; + color: var(--danger); +} + +.ml-note { + margin-top: 12px; + font-size: 12px; + color: var(--text-muted); +} + +.ml-note.center { + margin-top: 0; + text-align: center; +} + +.ml-detail-footer { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px 16px 16px; + border-top: 1px solid var(--border); +} + +.ml-cta { + width: 100%; + height: 38px; + border: none; + border-radius: 10px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, opacity 0.15s; +} + +.ml-cta.small { + width: auto; + height: 30px; + padding: 0 12px; +} + +.ml-cta:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.ml-cta.primary { + background: var(--accent); + color: #000; +} + +.ml-cta.primary:hover:not(:disabled) { + background: var(--accent-dim); +} + +.ml-cta.secondary { + background: var(--bg-hover); + color: var(--text-primary); +} + +.ml-cta.secondary:hover:not(:disabled) { + background: var(--bg-active); +} + +.ml-cta.active { + background: rgba(0, 255, 78, 0.12); + color: var(--accent); + opacity: 1; + cursor: default; +} + +.ml-cta.danger { + background: var(--danger); + color: #fff; +} + +.ml-link { + align-self: center; + border: none; + background: transparent; + font-size: 12px; + color: var(--text-muted); + cursor: pointer; +} + +.ml-link.danger:hover:not(:disabled) { + color: var(--danger); +} + +.ml-confirm { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 8px; + font-size: 12px; + color: var(--text-secondary); +} + +.ml-detail-progress { + display: flex; + flex-direction: column; + gap: 10px; +} + +.ml-progress { + display: flex; + flex-direction: column; + gap: 6px; +} + +.ml-progress-track { + height: 6px; + border-radius: 3px; + background: var(--bg-hover); + overflow: hidden; +} + +.ml-progress-fill { + height: 100%; + background: linear-gradient(90deg, var(--accent), #00d142); + transition: width 0.3s ease; +} + +.ml-progress-text { + font-size: 12px; + color: var(--text-secondary); + font-variant-numeric: tabular-nums; +} + +/* Wide windows: the panel sits beside the list instead of covering it */ +@media (min-width: 1180px) { + .models-page.with-detail .ml-table-wrap { + margin-right: 420px; + } + + .ml-detail { + width: 420px; + box-shadow: none; + } +} + +/* Settings and Home integration */ +.model-summary { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.model-summary-text { + text-align: right; + font-size: 13px; + color: var(--text-secondary); +} + +.model-summary-text strong { + display: block; + font-size: 14px; + color: var(--text-primary); + font-weight: 500; +} + +.link-button { + padding: 8px 14px; + background: var(--bg-hover); + border: none; + border-radius: 8px; + color: var(--text-primary); + font-size: 13px; + font-weight: 500; + cursor: pointer; + white-space: nowrap; +} + +.link-button:hover { + background: var(--bg-active); +} + +.download-prompt .download-prompt-actions { + display: flex; + justify-content: center; + gap: 8px; + flex-wrap: wrap; +} + +.download-prompt .download-prompt-actions .link-button { + background: var(--bg-hover); + color: var(--text-primary); + font-weight: 500; +} + +.download-prompt .download-prompt-actions .link-button:hover { + background: var(--bg-active); + transform: none; + box-shadow: none; +} + +.download-prompt .download-prompt-error { + color: var(--danger); + font-size: 13px; +} + +.download-progress .download-progress-actions { + display: flex; + justify-content: center; + gap: 8px; + margin-top: 14px; +} + +.download-prompt button:disabled, +.link-button:disabled { + opacity: 0.45; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.header-actions { + position: relative; + z-index: 40; + margin-left: auto; + display: flex; + align-items: center; + -webkit-app-region: no-drag; +} + +.ml-spec-text { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.2px; +} + +/* Keep the header usable at the 850x550 minimum: chips scroll, search shrinks */ +.header-actions { + flex: 1; + min-width: 0; + justify-content: flex-end; + padding-left: 16px; +} + +.page-title { + flex-shrink: 0; +} + +.ml-toolbar { + min-width: 0; +} + +.ml-chips { + display: flex; + gap: 6px; + min-width: 0; + overflow-x: auto; + scrollbar-width: none; +} + +.ml-chips::-webkit-scrollbar { + display: none; +} + +.ml-toolbar > .ml-icon-btn, +.ml-menu-anchor { + flex-shrink: 0; +} + +.ml-search { + flex: 0 1 190px; + min-width: 110px; +} + +.ml-search input { + width: 100%; + min-width: 0; +} + +.ml-star:disabled { + opacity: 0.6; + cursor: progress; +} + +.ml-star[aria-disabled='true'] { + opacity: 0.6; + cursor: progress; +} diff --git a/frontend/src/ModelsLibrary.test.tsx b/frontend/src/ModelsLibrary.test.tsx new file mode 100644 index 0000000..125c85f --- /dev/null +++ b/frontend/src/ModelsLibrary.test.tsx @@ -0,0 +1,305 @@ +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { ModelFilter, ModelsLibrary, ModelsLibraryProps, filterModels, sortModels } from './ModelsLibrary'; +import { IDLE_DOWNLOAD } from './modelTypes'; +import { MODELS, downloadState } from './test/fixtures'; + +function renderLibrary(overrides: Partial = {}) { + const props: ModelsLibraryProps = { + models: MODELS, + activeModel: 'base.en', + provider: 'local', + downloadState: IDLE_DOWNLOAD, + onDownload: vi.fn(() => Promise.resolve()), + onCancel: vi.fn(() => Promise.resolve()), + onUse: vi.fn(() => Promise.resolve()), + onDelete: vi.fn(() => Promise.resolve()), + onToggleFavorite: vi.fn(() => Promise.resolve()), + ...overrides, + }; + const utils = render(); + return { props, ...utils }; +} + +const row = (name: string) => screen.getByTestId(`model-row-${name}`); +const visibleNames = () => screen.queryAllByTestId(/^model-row-/).map((el) => el.getAttribute('data-testid')!.replace('model-row-', '')); +const detail = () => screen.getByRole('dialog'); +const filters = (...f: ModelFilter[]) => new Set(f); +const names = (models: { name: string }[]) => models.map((m) => m.name); + +describe('filterModels / sortModels', () => { + it('searches names, vendor and descriptive metadata', () => { + expect(names(filterModels(MODELS, 'large', filters()))).toEqual(['large-v3']); + expect(names(filterModels(MODELS, 'low-power', filters()))).toEqual(['tiny.en']); + expect(names(filterModels(MODELS, 'recommended', filters()))).toEqual(['base.en', 'small']); + expect(names(filterModels(MODELS, 'openai multilingual most accurate', filters()))).toEqual(['large-v3']); + }); + + it('combines language, download and favorite filters', () => { + expect(names(filterModels(MODELS, '', filters('english')))).toEqual(['tiny.en', 'base.en']); + expect(names(filterModels(MODELS, '', filters('multilingual')))).toEqual(['base', 'small', 'large-v3']); + expect(names(filterModels(MODELS, '', filters('english', 'multilingual')))).toHaveLength(MODELS.length); + expect(names(filterModels(MODELS, '', filters('downloaded')))).toEqual(['base.en', 'base']); + expect(names(filterModels(MODELS, '', filters('downloaded', 'favorites')))).toEqual(['base']); + }); + + it('sorts by name with multilingual before English variants, and by ratings and size', () => { + expect(names(sortModels(MODELS, { key: 'name', dir: 1 }))).toEqual(['base', 'base.en', 'large-v3', 'small', 'tiny.en']); + expect(names(sortModels(MODELS, { key: 'name', dir: -1 }))).toEqual(['tiny.en', 'small', 'large-v3', 'base.en', 'base']); + expect(names(sortModels(MODELS, { key: 'speed', dir: -1 }))[0]).toBe('tiny.en'); + expect(names(sortModels(MODELS, { key: 'accuracy', dir: -1 }))[0]).toBe('large-v3'); + expect(names(sortModels(MODELS, { key: 'size', dir: 1 }))[0]).toBe('tiny.en'); + }); +}); + +describe('ModelsLibrary table', () => { + it('lists every model with logo, EN badge, recommendation, ratings and size', () => { + renderLibrary(); + expect(visibleNames()).toEqual(['base', 'base.en', 'large-v3', 'small', 'tiny.en']); + const tiny = within(row('tiny.en')); + expect(tiny.getByText('Whisper Tiny')).toBeInTheDocument(); + expect(tiny.getByText('EN')).toHaveAttribute('title', 'English only'); + expect(tiny.getByRole('img', { name: 'OpenAI' })).toBeInTheDocument(); + expect(tiny.getByRole('img', { name: 'Speed: Fastest (5 of 5)' })).toBeInTheDocument(); + expect(tiny.getByRole('img', { name: 'Accuracy: Basic (1 of 5)' })).toBeInTheDocument(); + expect(tiny.getByText('78 MB')).toBeInTheDocument(); + expect(within(row('large-v3')).queryByText('EN')).toBeNull(); + expect(within(row('small')).getByText('Recommended')).toBeInTheDocument(); + }); + + it('shows active, downloaded and downloadable states in the status column', () => { + renderLibrary(); + expect(within(row('base.en')).getByText('Active')).toBeInTheDocument(); + expect(within(row('base.en')).getByRole('img', { name: 'Whisper Base (English) is active' })).toBeInTheDocument(); + expect(within(row('base')).getByRole('img', { name: 'Whisper Base downloaded' })).toBeInTheDocument(); + expect(within(row('small')).getByRole('button', { name: 'Download Whisper Small' })).toBeEnabled(); + }); + + it('sorts when a column header is clicked', () => { + renderLibrary(); + fireEvent.click(screen.getByRole('button', { name: 'Size' })); + expect(visibleNames()[0]).toBe('large-v3'); + fireEvent.click(screen.getByRole('button', { name: 'Size' })); + expect(visibleNames()[0]).toBe('tiny.en'); + fireEvent.click(screen.getByRole('button', { name: /Model name/ })); + expect(visibleNames()[0]).toBe('base'); + }); + + it('searches from the toolbar', () => { + renderLibrary(); + fireEvent.click(screen.getByRole('button', { name: 'Search models' })); + fireEvent.change(screen.getByRole('searchbox', { name: 'Search models' }), { target: { value: 'small' } }); + expect(visibleNames()).toEqual(['small']); + fireEvent.change(screen.getByRole('searchbox', { name: 'Search models' }), { target: { value: 'zzz' } }); + expect(screen.getByText('No models match your search or filters.')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Close search' })); + expect(visibleNames()).toHaveLength(MODELS.length); + }); + + it('filters through the menu and removable chips', () => { + renderLibrary(); + fireEvent.click(screen.getByRole('button', { name: 'Filter models' })); + const menu = screen.getByRole('menu', { name: 'Filter models' }); + expect(within(menu).getByRole('menuitemcheckbox', { name: /Downloaded/ })).toHaveTextContent('Downloaded2'); + fireEvent.click(within(menu).getByRole('menuitemcheckbox', { name: /Downloaded/ })); + expect(visibleNames()).toEqual(['base', 'base.en']); + fireEvent.click(within(menu).getByRole('menuitemcheckbox', { name: /Favorites/ })); + expect(visibleNames()).toEqual(['base']); + expect(within(menu).getByRole('menuitemcheckbox', { name: /Favorites/ })).toHaveAttribute('aria-checked', 'true'); + + fireEvent.keyDown(document, { key: 'Escape' }); + expect(screen.queryByRole('menu')).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'Remove Favorites filter' })); + fireEvent.click(screen.getByRole('button', { name: 'Remove Downloaded filter' })); + expect(visibleNames()).toHaveLength(MODELS.length); + }); + + it('stars optimistically, blocks repeat clicks while saving, then follows the server', async () => { + let resolveSave!: () => void; + const onToggleFavorite = vi.fn((_model: string, _favorite: boolean) => new Promise((r) => { resolveSave = r; })); + const { props, rerender } = renderLibrary({ onToggleFavorite }); + fireEvent.click(within(row('small')).getByRole('button', { name: 'Add Whisper Small to favorites' })); + expect(onToggleFavorite).toHaveBeenCalledWith('small', true); + const saving = within(row('small')).getByRole('button', { name: 'Remove Whisper Small from favorites' }); + expect(saving).toHaveAttribute('aria-pressed', 'true'); + expect(saving).toHaveAttribute('aria-disabled', 'true'); + fireEvent.click(saving); + expect(onToggleFavorite).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('dialog')).toBeNull(); + + // The app refreshes models before the save resolves. + rerender( (m.name === 'small' ? { ...m, favorite: true } : m))} />); + await act(async () => resolveSave()); + expect(within(row('small')).getByRole('button', { name: 'Remove Whisper Small from favorites' })).not.toHaveAttribute('aria-disabled'); + }); + + it('shows the server state and an error when saving a favorite fails', async () => { + renderLibrary({ onToggleFavorite: vi.fn(() => Promise.reject('config is read-only')) }); + fireEvent.click(within(row('small')).getByRole('button', { name: 'Add Whisper Small to favorites' })); + const panel = await screen.findByRole('dialog', { name: 'Whisper Small details' }); + expect(within(panel).getByRole('alert')).toHaveTextContent('Couldn’t update favorites: config is read-only'); + expect(within(row('small')).getByRole('button', { name: 'Add Whisper Small to favorites' })).toHaveAttribute('aria-pressed', 'false'); + }); + + it('downloads from the row icon without opening details', async () => { + const { props } = renderLibrary(); + fireEvent.click(within(row('small')).getByRole('button', { name: 'Download Whisper Small' })); + await waitFor(() => expect(props.onDownload).toHaveBeenCalledWith('small')); + expect(screen.queryByRole('dialog')).toBeNull(); + }); + + it('opens details with the error when a row download is rejected', async () => { + renderLibrary({ onDownload: vi.fn(() => Promise.reject('another model is already downloading: base')) }); + fireEvent.click(within(row('small')).getByRole('button', { name: 'Download Whisper Small' })); + const panel = await screen.findByRole('dialog', { name: 'Whisper Small details' }); + expect(within(panel).getByRole('alert')).toHaveTextContent('another model is already downloading: base'); + }); + + it('shows progress in the row and disables other downloads', () => { + renderLibrary({ downloadState: downloadState({ model: 'small', status: 'downloading', downloaded: 243800983, total: 487601967, progress: 50 }) }); + expect(within(row('small')).getByText('50%')).toBeInTheDocument(); + expect(within(row('small')).getByLabelText('Downloading Whisper Small')).toBeInTheDocument(); + const other = within(row('large-v3')).getByRole('button', { name: 'Download Whisper Large v3' }); + expect(other).toBeDisabled(); + expect(other).toHaveAttribute('title', 'Another model is downloading'); + }); + + it('marks failed downloads in the row and offers retry', async () => { + const { props } = renderLibrary({ downloadState: downloadState({ model: 'small', status: 'failed', error: 'server returned HTTP 503' }) }); + const retry = within(row('small')).getByRole('button', { name: 'Retry downloading Whisper Small' }); + expect(retry).toHaveAttribute('title', 'Download failed: server returned HTTP 503'); + fireEvent.click(retry); + await waitFor(() => expect(props.onDownload).toHaveBeenCalledWith('small')); + }); + + it('explains that local models are unused while OpenAI is active', () => { + renderLibrary({ provider: 'openai' }); + expect(screen.getByText(/OpenAI is your current provider/)).toBeInTheDocument(); + }); + + it('renders the toolbar into a supplied header target', () => { + const target = document.createElement('div'); + document.body.appendChild(target); + renderLibrary({ toolbarTarget: target }); + expect(within(target).getByRole('button', { name: 'Filter models' })).toBeInTheDocument(); + target.remove(); + }); +}); + +describe('ModelsLibrary detail panel', () => { + it('opens from a row with description, specs, ratings and size', () => { + renderLibrary(); + fireEvent.click(row('large-v3')); + const panel = within(detail()); + expect(detail()).toHaveAccessibleName('Whisper Large v3 details'); + expect(panel.getByText('Most accurate')).toBeInTheDocument(); + expect(panel.getByText('Maximum accuracy in any language.')).toBeInTheDocument(); + expect(panel.getByText('Multilingual (99 languages)')).toBeInTheDocument(); + expect(panel.getByRole('img', { name: 'High accuracy' })).toBeInTheDocument(); + expect(panel.getByRole('img', { name: 'Multilingual' })).toBeInTheDocument(); + expect(panel.queryByRole('img', { name: 'Fast' })).toBeNull(); + expect(panel.getByRole('img', { name: 'Accuracy: Highest (5 of 5)' })).toBeInTheDocument(); + expect(panel.getByText('3.1 GB')).toBeInTheDocument(); + expect(panel.getByRole('button', { name: 'Download · 3.1 GB' })).toBeEnabled(); + }); + + it('opens from the keyboard and closes with Escape or the back button', () => { + renderLibrary(); + fireEvent.keyDown(within(row('tiny.en')).getByRole('button', { name: 'Show Whisper Tiny (English) details' }), { key: 'Enter' }); + expect(detail()).toHaveAccessibleName('Whisper Tiny (English) details'); + expect(within(detail()).getByRole('img', { name: 'English only' })).toBeInTheDocument(); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(screen.queryByRole('dialog')).toBeNull(); + fireEvent.click(row('tiny.en')); + fireEvent.click(screen.getByRole('button', { name: 'Close details' })); + expect(screen.queryByRole('dialog')).toBeNull(); + }); + + it('returns focus to the row after closing', () => { + renderLibrary(); + const cell = within(row('large-v3')).getByRole('button', { name: 'Show Whisper Large v3 details' }); + fireEvent.keyDown(cell, { key: 'Enter' }); + expect(screen.getByRole('button', { name: 'Close details' })).toHaveFocus(); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(screen.queryByRole('dialog')).toBeNull(); + expect(cell).toHaveFocus(); + }); + + it('ignores Escape already handled by the app (e.g. cancelling a recording)', () => { + renderLibrary({ focusModel: 'small' }); + const event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true }); + event.preventDefault(); + document.dispatchEvent(event); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('opens the focused model on mount', () => { + renderLibrary({ focusModel: 'small' }); + expect(detail()).toHaveAccessibleName('Whisper Small details'); + expect(row('small')).toHaveClass('selected'); + }); + + it('downloads, shows progress and cancels', async () => { + const { props, rerender } = renderLibrary({ focusModel: 'small' }); + fireEvent.click(within(detail()).getByRole('button', { name: 'Download · 488 MB' })); + await waitFor(() => expect(props.onDownload).toHaveBeenCalledWith('small')); + + rerender(); + expect(within(detail()).getByRole('progressbar', { name: 'Downloading Whisper Small' })).toHaveAttribute('aria-valuenow', '50'); + expect(within(detail()).getByText('244 MB of 488 MB · 50%')).toBeInTheDocument(); + fireEvent.click(within(detail()).getByRole('button', { name: 'Cancel download' })); + await waitFor(() => expect(props.onCancel).toHaveBeenCalledWith('small')); + + rerender(); + expect(within(detail()).getByRole('button', { name: 'Cancelling…' })).toBeDisabled(); + + rerender(); + expect(within(detail()).getByText('Download cancelled.')).toBeInTheDocument(); + expect(within(detail()).getByRole('button', { name: 'Retry download · 488 MB' })).toBeEnabled(); + }); + + it('shows inline failures with retry', async () => { + const { props } = renderLibrary({ focusModel: 'small', downloadState: downloadState({ model: 'small', status: 'failed', error: 'server returned HTTP 503' }) }); + expect(within(detail()).getByRole('alert')).toHaveTextContent('Download failed: server returned HTTP 503'); + fireEvent.click(within(detail()).getByRole('button', { name: 'Retry download · 488 MB' })); + await waitFor(() => expect(props.onDownload).toHaveBeenCalledWith('small')); + }); + + it('activates and deletes downloaded inactive models, with confirmation', async () => { + const { props } = renderLibrary({ focusModel: 'base' }); + fireEvent.click(within(detail()).getByRole('button', { name: 'Use this model' })); + await waitFor(() => expect(props.onUse).toHaveBeenCalledWith('base')); + + fireEvent.click(within(detail()).getByRole('button', { name: 'Delete from this computer' })); + fireEvent.click(within(detail()).getByRole('button', { name: 'Keep' })); + expect(props.onDelete).not.toHaveBeenCalled(); + fireEvent.click(within(detail()).getByRole('button', { name: 'Delete from this computer' })); + fireEvent.click(within(detail()).getByRole('button', { name: 'Confirm delete' })); + await waitFor(() => expect(props.onDelete).toHaveBeenCalledWith('base')); + }); + + it('protects the active model', () => { + renderLibrary({ focusModel: 'base.en' }); + expect(within(detail()).getByRole('button', { name: 'Active model' })).toBeDisabled(); + expect(within(detail()).queryByRole('button', { name: 'Delete from this computer' })).toBeNull(); + expect(within(detail()).getByText('The active model can’t be deleted.')).toBeInTheDocument(); + }); + + it('shows rejected actions inline', async () => { + renderLibrary({ focusModel: 'base', onDelete: vi.fn(() => Promise.reject('the active model cannot be deleted')) }); + fireEvent.click(within(detail()).getByRole('button', { name: 'Delete from this computer' })); + fireEvent.click(within(detail()).getByRole('button', { name: 'Confirm delete' })); + expect(await within(detail()).findByRole('alert')).toHaveTextContent('the active model cannot be deleted'); + }); + + it('stars from the detail header', async () => { + let resolveSave!: () => void; + const onToggleFavorite = vi.fn((_model: string, _favorite: boolean) => new Promise((r) => { resolveSave = r; })); + renderLibrary({ focusModel: 'small', onToggleFavorite }); + fireEvent.click(within(detail()).getByRole('button', { name: 'Add Whisper Small to favorites' })); + expect(onToggleFavorite).toHaveBeenCalledWith('small', true); + expect(within(detail()).getByRole('button', { name: 'Remove Whisper Small from favorites' })).toBeDisabled(); + expect(within(row('small')).getByRole('button', { name: 'Remove Whisper Small from favorites' })).toBeInTheDocument(); + await act(async () => resolveSave()); + }); +}); diff --git a/frontend/src/ModelsLibrary.tsx b/frontend/src/ModelsLibrary.tsx new file mode 100644 index 0000000..af0e50d --- /dev/null +++ b/frontend/src/ModelsLibrary.tsx @@ -0,0 +1,645 @@ +import { KeyboardEvent as ReactKeyboardEvent, ReactNode, useEffect, useMemo, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import './ModelsLibrary.css'; +import { + ModelDownloadState, + ModelInfo, + accuracyLabel, + errorMessage, + formatBytes, + fullModelName, + isDownloadActive, + languageLabel, + speedLabel, +} from './modelTypes'; +import { ProviderLogo, VENDOR_NAMES, modelVendor } from './ProviderLogo'; + +export type ModelFilter = 'english' | 'multilingual' | 'downloaded' | 'favorites'; +export type SortKey = 'name' | 'speed' | 'accuracy' | 'size'; +export interface SortState { + key: SortKey; + dir: 1 | -1; +} + +const FILTER_LABELS: Record = { + english: 'English only', + multilingual: 'Multilingual', + downloaded: 'Downloaded', + favorites: 'Favorites', +}; +const FILTER_ORDER: ModelFilter[] = ['english', 'multilingual', 'downloaded', 'favorites']; + +export interface ModelsLibraryProps { + models: ModelInfo[]; + activeModel: string; + provider: string; + downloadState: ModelDownloadState; + focusModel?: string | null; + // Where to render search/filter controls (the page header). Inline when absent. + toolbarTarget?: HTMLElement | null; + onDownload: (model: string) => Promise; + onCancel: (model: string) => Promise; + onUse: (model: string) => Promise; + onDelete: (model: string) => Promise; + onToggleFavorite: (model: string, favorite: boolean) => Promise; +} + +function searchText(model: ModelInfo): string { + return [ + model.displayName, + model.name, + model.useCase, + model.recommendation, + model.size, + languageLabel(model), + model.englishOnly ? 'en english' : 'multilingual', + VENDOR_NAMES[modelVendor(model)], + 'whisper', + `${speedLabel(model.speed)} speed`, + `${accuracyLabel(model.accuracy)} accuracy`, + ].join(' ').toLowerCase(); +} + +export function filterModels(models: ModelInfo[], query: string, filters: ReadonlySet): ModelInfo[] { + const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean); + const english = filters.has('english'); + const multilingual = filters.has('multilingual'); + return models.filter((model) => { + if (english !== multilingual) { + if (english && !model.englishOnly) return false; + if (multilingual && model.englishOnly) return false; + } + if (filters.has('downloaded') && !model.downloaded) return false; + if (filters.has('favorites') && !model.favorite) return false; + if (terms.length === 0) return true; + const haystack = searchText(model); + return terms.every((term) => haystack.includes(term)); + }); +} + +export function sortModels(models: ModelInfo[], sort: SortState): ModelInfo[] { + const byName = (a: ModelInfo, b: ModelInfo) => + a.displayName.localeCompare(b.displayName) || Number(a.englishOnly) - Number(b.englishOnly); + const value = (m: ModelInfo) => (sort.key === 'speed' ? m.speed : sort.key === 'accuracy' ? m.accuracy : m.sizeBytes); + return [...models].sort((a, b) => { + if (sort.key === 'name') return byName(a, b) * sort.dir; + const diff = value(a) - value(b); + return diff ? diff * sort.dir : byName(a, b); + }); +} + +function Segments({ label, value, text }: { label: string; value: number; text: string }) { + return ( + + {[1, 2, 3, 4, 5].map((i) => ( + + ))} + + ); +} + +function Icon({ name }: { name: 'star' | 'star-filled' | 'search' | 'filter' | 'download' | 'check' | 'alert' | 'back' | 'close' | 'chevron' }) { + const common = { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const, 'aria-hidden': true }; + switch (name) { + case 'star': + case 'star-filled': + return ( + + + + ); + case 'search': + return ; + case 'filter': + return ; + case 'download': + return ; + case 'check': + return ; + case 'alert': + return ; + case 'back': + return ; + case 'close': + return ; + case 'chevron': + return ; + } +} + +function ProgressRing({ progress }: { progress: number }) { + const circumference = 2 * Math.PI * 9; + return ( + + ); +} + +interface SpecItem { + id: string; + label: string; + tone: 'yellow' | 'blue' | 'purple' | 'green' | 'grey'; + glyph?: ReactNode; + text?: string; +} + +export function modelSpecs(model: ModelInfo): SpecItem[] { + const specs: SpecItem[] = []; + if (model.speed >= 4) { + specs.push({ id: 'fast', label: 'Fast', tone: 'yellow', glyph: }); + } + if (model.accuracy >= 4) { + specs.push({ id: 'accurate', label: 'High accuracy', tone: 'purple', glyph: <> }); + } + specs.push(model.englishOnly + ? { id: 'english', label: 'English only', tone: 'blue', text: 'EN' } + : { id: 'multilingual', label: 'Multilingual', tone: 'blue', glyph: <> }); + specs.push({ id: 'offline', label: 'Runs offline on this computer', tone: 'green', glyph: <> }); + return specs; +} + +interface ModelStatus { + downloading: boolean; + cancelling: boolean; + failed: boolean; + cancelled: boolean; + otherDownloading: boolean; +} + +function statusFor(model: ModelInfo, downloadState: ModelDownloadState): ModelStatus { + const isThis = downloadState.model === model.name; + const active = isDownloadActive(downloadState); + return { + downloading: isThis && active, + cancelling: isThis && downloadState.status === 'cancelling', + failed: isThis && downloadState.status === 'failed' && !model.downloaded, + cancelled: isThis && downloadState.status === 'cancelled' && !model.downloaded, + otherDownloading: !isThis && active, + }; +} + +interface DetailProps { + model: ModelInfo; + active: boolean; + downloadState: ModelDownloadState; + actionError: string; + pending: boolean; + onClose: () => void; + onRun: (action: (name: string) => Promise) => void; + onDownload: (model: string) => Promise; + onCancel: (model: string) => Promise; + onUse: (model: string) => Promise; + onDelete: (model: string) => Promise; + onToggleFavorite: () => void; + favoriteSaving: boolean; +} + +function ModelDetail({ model, active, downloadState, actionError, pending, onClose, onRun, onDownload, onCancel, onUse, onDelete, onToggleFavorite, favoriteSaving }: DetailProps) { + const [confirmingDelete, setConfirmingDelete] = useState(false); + const closeRef = useRef(null); + const status = statusFor(model, downloadState); + const total = downloadState.total || model.sizeBytes; + const name = fullModelName(model); + + useEffect(() => { + closeRef.current?.focus(); + }, [model.name]); + + useEffect(() => { + setConfirmingDelete(false); + }, [model.name, model.downloaded]); + + let primary: ReactNode; + if (status.downloading) { + primary = ( +
+
+
+ + {formatBytes(downloadState.downloaded)} of {formatBytes(total)} · {downloadState.progress.toFixed(0)}% + +
+ +
+ ); + } else if (!model.downloaded) { + primary = ( + + ); + } else if (active) { + primary = ; + } else { + primary = ; + } + + return ( + + ); +} + +export function ModelsLibrary({ + models, + activeModel, + provider, + downloadState, + focusModel, + toolbarTarget, + onDownload, + onCancel, + onUse, + onDelete, + onToggleFavorite, +}: ModelsLibraryProps) { + const [query, setQuery] = useState(''); + const [searchOpen, setSearchOpen] = useState(false); + const [filters, setFilters] = useState>(new Set()); + const [menuOpen, setMenuOpen] = useState(false); + const [sort, setSort] = useState({ key: 'name', dir: 1 }); + const [selected, setSelected] = useState(focusModel ?? null); + const [errors, setErrors] = useState>({}); + const [pending, setPending] = useState(null); + // Optimistic star state, kept only while the save is in flight; afterwards + // the refreshed models prop is the source of truth. + const [favoriteOverrides, setFavoriteOverrides] = useState>({}); + const menuRef = useRef(null); + const searchRef = useRef(null); + + const withFavorites = useMemo( + () => models.map((m) => (m.name in favoriteOverrides ? { ...m, favorite: favoriteOverrides[m.name] } : m)), + [models, favoriteOverrides], + ); + const visible = useMemo(() => sortModels(filterModels(withFavorites, query, filters), sort), [withFavorites, query, filters, sort]); + const selectedModel = withFavorites.find((m) => m.name === selected) ?? null; + const counts = useMemo(() => ({ + english: withFavorites.filter((m) => m.englishOnly).length, + multilingual: withFavorites.filter((m) => !m.englishOnly).length, + downloaded: withFavorites.filter((m) => m.downloaded).length, + favorites: withFavorites.filter((m) => m.favorite).length, + }), [withFavorites]); + + useEffect(() => { + if (focusModel) setSelected(focusModel); + }, [focusModel]); + + useEffect(() => { + if (searchOpen) searchRef.current?.focus(); + }, [searchOpen]); + + useEffect(() => { + if (!menuOpen) return; + const onPointer = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) setMenuOpen(false); + }; + document.addEventListener('mousedown', onPointer); + return () => document.removeEventListener('mousedown', onPointer); + }, [menuOpen]); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + // App cancels an active recording on Escape and marks the event handled. + if (e.key !== 'Escape' || e.defaultPrevented) return; + if (menuOpen) setMenuOpen(false); + else if (selected) closeDetail(); + else if (searchOpen) { setSearchOpen(false); setQuery(''); } + }; + document.addEventListener('keydown', onKey); + return () => document.removeEventListener('keydown', onKey); + }); + + const tableRef = useRef(null); + + // Return focus to the row that opened the panel, so keyboard users keep + // their place. + const closeDetail = () => { + const name = selected; + setSelected(null); + if (!name) return; + const cell = tableRef.current?.querySelector(`[data-testid="model-row-${name}"] .ml-name-cell`); + cell?.focus(); + }; + + const run = async (name: string, action: (model: string) => Promise) => { + setPending(name); + setErrors((prev) => ({ ...prev, [name]: '' })); + try { + await action(name); + } catch (err) { + setErrors((prev) => ({ ...prev, [name]: errorMessage(err) })); + setSelected(name); + } finally { + setPending(null); + } + }; + + const clearFavoriteOverride = (name: string) => { + setFavoriteOverrides((prev) => { + const next = { ...prev }; + delete next[name]; + return next; + }); + }; + + const toggleFavorite = async (model: ModelInfo) => { + if (model.name in favoriteOverrides) return; + const next = !model.favorite; + setFavoriteOverrides((prev) => ({ ...prev, [model.name]: next })); + try { + await onToggleFavorite(model.name, next); + } catch (err) { + setErrors((prev) => ({ ...prev, [model.name]: `Couldn’t update favorites: ${errorMessage(err)}` })); + setSelected(model.name); + } finally { + clearFavoriteOverride(model.name); + } + }; + + const toggleFilter = (filter: ModelFilter) => { + setFilters((prev) => { + const next = new Set(prev); + if (next.has(filter)) next.delete(filter); + else next.add(filter); + return next; + }); + }; + + const toggleSort = (key: SortKey) => { + setSort((prev) => (prev.key === key ? { key, dir: prev.dir === 1 ? -1 : 1 } : { key, dir: key === 'name' ? 1 : -1 })); + }; + + const sortHeader = (key: SortKey, label: string, className: string) => { + const activeSort = sort.key === key; + return ( + + + + ); + }; + + const toolbar = ( +
+ {filters.size > 0 && ( +
+ {FILTER_ORDER.filter((f) => filters.has(f)).map((f) => ( + + {FILTER_LABELS[f]} + + + ))} +
+ )} + {searchOpen ? ( +
+ + setQuery(e.target.value)} + /> + +
+ ) : ( + + )} +
+ + {menuOpen && ( +
+ {FILTER_ORDER.map((f) => ( + + ))} +
+ )} +
+
+ ); + + const rowStatus = (model: ModelInfo) => { + const status = statusFor(model, downloadState); + const name = fullModelName(model); + if (status.downloading) { + return ( + + {downloadState.progress.toFixed(0)}% + + + ); + } + if (model.downloaded) { + const isActive = model.name === activeModel; + return ( + + {isActive ? Active : {model.size}} + + + + + ); + } + return ( + + {model.size} + + + ); + }; + + const onRowKey = (e: ReactKeyboardEvent, model: ModelInfo) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + setSelected(model.name); + } + }; + + return ( +
+ {toolbarTarget ? createPortal(toolbar, toolbarTarget) : toolbar} + + {provider === 'openai' && ( +

+ OpenAI is your current provider, so local models aren’t used until you switch back to Local in Settings. +

+ )} + +
+ + + + + {sortHeader('name', 'Model name', 'col-name')} + {sortHeader('speed', 'Speed', 'col-rating')} + {sortHeader('accuracy', 'Accuracy', 'col-rating')} + {sortHeader('size', 'Size', 'col-status')} + + + + {visible.map((model) => { + const name = fullModelName(model); + return ( + setSelected(model.name)} + > + + + + + + + ); + })} + +
Favorite
+ + + onRowKey(e, model)}> + + {model.displayName} + {model.englishOnly && EN} + {model.recommendation.startsWith('Recommended') && Recommended} + + {rowStatus(model)}
+ {visible.length === 0 &&

No models match your search or filters.

} +
+ + {selectedModel && ( + run(selectedModel.name, action)} + onDownload={onDownload} + onCancel={onCancel} + onUse={onUse} + onDelete={onDelete} + onToggleFavorite={() => toggleFavorite(selectedModel)} + favoriteSaving={selectedModel.name in favoriteOverrides} + /> + )} +
+ ); +} diff --git a/frontend/src/Onboarding.css b/frontend/src/Onboarding.css index 0951b49..eb98cff 100644 --- a/frontend/src/Onboarding.css +++ b/frontend/src/Onboarding.css @@ -221,17 +221,41 @@ .model-header { display: flex; align-items: center; - justify-content: space-between; - gap: 10px; + gap: 12px; + min-height: 24px; } .model-name { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 8px; font-size: 15px; font-weight: 500; color: var(--text-primary); - display: flex; - align-items: center; - gap: 10px; + white-space: nowrap; +} + +.model-title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.model-tag { + flex-shrink: 0; + padding: 1px 5px; + border-radius: 4px; + background: var(--bg-card); + color: var(--text-secondary); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.3px; +} + +.model-card.selected .model-tag { + background: var(--bg-active); } .recommended-badge { @@ -244,25 +268,55 @@ text-transform: uppercase; } +.model-name .recommended-badge { + flex-shrink: 0; + padding: 2px 6px; + font-size: 9.5px; + letter-spacing: 0.3px; +} + +.model-meta { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 10px; +} + .model-size { - font-size: 13px; + font-size: 12px; font-weight: 500; color: var(--text-secondary); background: var(--bg-card); - padding: 4px 10px; + padding: 3px 8px; border-radius: 6px; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.model-card.selected .model-size { + background: var(--bg-active); +} + +/* Always reserved so sizes line up whether or not a model is downloaded */ +.model-status { + width: 16px; + height: 16px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--success); } .model-desc { font-size: 13px; color: var(--text-muted); - margin-top: 6px; + margin-top: 4px; line-height: 1.4; } -.model-downloaded { - color: var(--success); - flex-shrink: 0; +.model-card:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; } /* Provider selection step */ @@ -1407,3 +1461,12 @@ .mockup-toggle.on::after { transform: translateX(14px); } + +.onboarding-sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} diff --git a/frontend/src/Onboarding.test.tsx b/frontend/src/Onboarding.test.tsx new file mode 100644 index 0000000..8d79510 --- /dev/null +++ b/frontend/src/Onboarding.test.tsx @@ -0,0 +1,176 @@ +import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as Api from '../wailsjs/go/main/App'; +import { Onboarding } from './Onboarding'; +import { EventsEmit, listenerCount, resetListeners } from './test/runtimeMock'; +import { MODELS, downloadState, model } from './test/fixtures'; +import { IDLE_DOWNLOAD } from './modelTypes'; + +vi.mock('../wailsjs/runtime/runtime', async () => await import('./test/runtimeMock')); +vi.mock('../wailsjs/go/main/App', async (importOriginal) => { + const actual = await importOriginal>(); + return Object.fromEntries(Object.keys(actual).map((key) => [key, vi.fn(() => Promise.resolve())])); +}); + +const api = Api as unknown as Record>; + +// tiny.en is not downloaded in the fixtures, so onboarding must download it. +async function startTinyDownload(prepare?: () => void) { + api.GetModels.mockResolvedValue(MODELS); + api.GetModelDownloadState.mockResolvedValue(IDLE_DOWNLOAD); + api.GetRecordingHotkeyDisplayName.mockResolvedValue('Right Option'); + api.GetPlatform.mockResolvedValue('darwin'); + api.CheckMicrophonePermission.mockResolvedValue('undetermined'); + prepare?.(); + render( {}} />); + fireEvent.click(screen.getByRole('button', { name: 'Get Started' })); + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + fireEvent.click(await screen.findByRole('radio', { name: 'Whisper Tiny (English)' })); + fireEvent.click(screen.getByRole('button', { name: 'Download & Continue' })); + await waitFor(() => expect(api.DownloadModel).toHaveBeenCalledWith('tiny.en')); +} + +describe('Onboarding model download', () => { + beforeEach(() => { + resetListeners(); + Object.values(api).forEach((fn) => { + fn.mockReset(); + fn.mockImplementation(() => Promise.resolve()); + }); + }); + + it('activates and advances only when its selected model completes', async () => { + await startTinyDownload(); + expect(listenerCount('modelDownloadState')).toBe(1); + + act(() => EventsEmit('modelDownloadState', downloadState({ model: 'tiny.en', status: 'downloading', downloaded: 38852357, total: 77704715, progress: 50 }))); + expect(screen.getByText('Downloading Whisper Tiny (English)')).toBeInTheDocument(); + expect(screen.getByText(/38\.9 MB of 77\.7 MB - 50%/)).toBeInTheDocument(); + + // A different model completing must not advance or activate anything. + act(() => EventsEmit('modelDownloadState', downloadState({ model: 'small', status: 'completed', progress: 100 }))); + expect(api.SetModel).not.toHaveBeenCalled(); + expect(screen.queryByText('Yap needs your microphone')).toBeNull(); + expect(document.querySelector('.download-step')).not.toBeNull(); + + act(() => EventsEmit('modelDownloadState', downloadState({ model: 'tiny.en', status: 'completed', downloaded: 77704715, total: 77704715, progress: 100 }))); + await waitFor(() => expect(api.SetModel).toHaveBeenCalledWith('tiny.en')); + expect(api.SetModel).toHaveBeenCalledTimes(1); + expect(await screen.findByText('Yap needs your microphone')).toBeInTheDocument(); + }); + + it('shows failures inline and retries the same model', async () => { + await startTinyDownload(); + act(() => EventsEmit('modelDownloadState', downloadState({ model: 'tiny.en', status: 'failed', error: 'download failed: HTTP 500' }))); + expect(screen.getByText('Download Failed')).toBeInTheDocument(); + expect(screen.getByText('download failed: HTTP 500')).toBeInTheDocument(); + expect(api.SetModel).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { name: 'Retry Download' })); + await waitFor(() => expect(api.DownloadModel).toHaveBeenCalledTimes(2)); + expect(api.DownloadModel).toHaveBeenLastCalledWith('tiny.en'); + }); + + it('can cancel its download and return to model selection', async () => { + await startTinyDownload(); + act(() => EventsEmit('modelDownloadState', downloadState({ model: 'tiny.en', status: 'downloading', downloaded: 1, total: 77704715, progress: 0 }))); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + await waitFor(() => expect(api.CancelModelDownload).toHaveBeenCalledWith('tiny.en')); + expect(await screen.findByText('Choose a Model')).toBeInTheDocument(); + act(() => EventsEmit('modelDownloadState', downloadState({ model: 'tiny.en', status: 'completed', progress: 100 }))); + expect(api.SetModel).not.toHaveBeenCalled(); + }); + + it('keeps waiting when the backend is already downloading the same model', async () => { + await startTinyDownload(() => { + api.DownloadModel.mockRejectedValue('another model is already downloading: tiny.en'); + // Mount sees idle; the backend is already downloading when rejected. + api.GetModelDownloadState + .mockResolvedValueOnce(IDLE_DOWNLOAD) + .mockResolvedValue(downloadState({ model: 'tiny.en', status: 'downloading', total: 77704715 })); + }); + await waitFor(() => expect(api.GetModelDownloadState).toHaveBeenCalledTimes(2)); + expect(screen.queryByText('Download Failed')).toBeNull(); + + act(() => EventsEmit('modelDownloadState', downloadState({ model: 'tiny.en', status: 'completed', progress: 100 }))); + await waitFor(() => expect(api.SetModel).toHaveBeenCalledWith('tiny.en')); + }); + + it('shows a busy rejection for a different model', async () => { + await startTinyDownload(() => { + api.DownloadModel.mockRejectedValue('another model is already downloading: small'); + api.GetModelDownloadState + .mockResolvedValueOnce(IDLE_DOWNLOAD) + .mockResolvedValue(downloadState({ model: 'small', status: 'downloading', total: 1 })); + }); + expect(await screen.findByText('another model is already downloading: small')).toBeInTheDocument(); + act(() => EventsEmit('modelDownloadState', downloadState({ model: 'tiny.en', status: 'completed', progress: 100 }))); + expect(api.SetModel).not.toHaveBeenCalled(); + }); + +}); + +describe('Onboarding model choice', () => { + beforeEach(() => { + resetListeners(); + Object.values(api).forEach((fn) => { + fn.mockReset(); + fn.mockImplementation(() => Promise.resolve()); + }); + }); + + async function openModelStep() { + api.GetModels.mockResolvedValue(MODELS.concat(model({ name: 'small.en', displayName: 'Whisper Small', size: '488 MB' }))); + api.GetModelDownloadState.mockResolvedValue(IDLE_DOWNLOAD); + api.GetRecordingHotkeyDisplayName.mockResolvedValue('Right Option'); + api.GetPlatform.mockResolvedValue('darwin'); + render( {}} />); + fireEvent.click(screen.getByRole('button', { name: 'Get Started' })); + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + return screen.findByRole('radiogroup', { name: 'Choose a Model' }); + } + + it('groups the cards and describes size, purpose, and download state', async () => { + const group = await openModelStep(); + const radios = within(group).getAllByRole('radio'); + expect(radios.map((r) => r.getAttribute('aria-label'))).toEqual([ + 'Whisper Tiny (English)', + 'Whisper Base (English), recommended', + 'Whisper Small (English)', + ]); + const base = within(group).getByRole('radio', { name: 'Whisper Base (English), recommended' }); + expect(base).toHaveAccessibleDescription(/148 MB.*Best balance of speed and accuracy.*Already downloaded/); + const small = within(group).getByRole('radio', { name: 'Whisper Small (English)' }); + expect(small).toHaveAccessibleDescription(/488 MB/); + expect(small).not.toHaveAccessibleDescription(/Already downloaded/); + expect(within(base).getByText('Recommended')).toBeInTheDocument(); + expect(within(small).queryByText('Recommended')).toBeNull(); + for (const radio of radios) expect(within(radio).getByText('EN')).toBeInTheDocument(); + }); + + it('uses one Tab stop and moves the selection with arrow keys', async () => { + const group = await openModelStep(); + const radio = (name: string) => within(group).getByRole('radio', { name }); + const tiny = radio('Whisper Tiny (English)'); + const base = radio('Whisper Base (English), recommended'); + const small = radio('Whisper Small (English)'); + expect(base).toHaveAttribute('aria-checked', 'true'); + expect([tiny, base, small].map((r) => r.tabIndex)).toEqual([-1, 0, -1]); + + fireEvent.keyDown(base, { key: 'ArrowDown' }); + expect(small).toHaveAttribute('aria-checked', 'true'); + expect(small).toHaveFocus(); + expect([tiny, base, small].map((r) => r.tabIndex)).toEqual([-1, -1, 0]); + + fireEvent.keyDown(small, { key: 'ArrowDown' }); + expect(tiny).toHaveAttribute('aria-checked', 'true'); + expect(tiny).toHaveFocus(); + + fireEvent.keyDown(tiny, { key: 'ArrowUp' }); + expect(small).toHaveAttribute('aria-checked', 'true'); + + fireEvent.keyDown(base, { key: ' ' }); + expect(base).toHaveAttribute('aria-checked', 'true'); + expect(small).toHaveAttribute('aria-checked', 'false'); + }); +}); diff --git a/frontend/src/Onboarding.tsx b/frontend/src/Onboarding.tsx index 4cd542b..df0c74f 100644 --- a/frontend/src/Onboarding.tsx +++ b/frontend/src/Onboarding.tsx @@ -4,6 +4,8 @@ import appIcon from './assets/appicon.png'; import { GetModels, DownloadModel, + CancelModelDownload, + GetModelDownloadState, SetModel, SetProvider, SetOpenAIKey, @@ -17,21 +19,8 @@ import { GetPlatform, } from '../wailsjs/go/main/App'; import { EventsOn, LogError, LogInfo } from '../wailsjs/runtime/runtime'; - -interface ModelInfo { - name: string; - displayName: string; - size: string; - downloaded: boolean; - englishOnly: boolean; -} - -interface DownloadProgress { - model: string; - downloaded: number; - total: number; - progress: number; -} +import { useModelDownload } from './useModelDownload'; +import { ModelDownloadState, ModelInfo, errorMessage, formatBytes, fullModelName, isDownloadActive } from './modelTypes'; interface AppState { state: string; @@ -59,8 +48,12 @@ export function Onboarding({ onComplete }: OnboardingProps) { const [selectedProvider, setSelectedProvider] = useState('local'); const [apiKey, setApiKey] = useState(''); const [apiKeyError, setApiKeyError] = useState(null); - const [downloadProgress, setDownloadProgress] = useState(null); - const [downloadError, setDownloadError] = useState(null); + const downloadState = useModelDownload(); + const [downloadStartError, setDownloadStartError] = useState(null); + // The model this onboarding session asked to download. Only its successful + // completion activates a model and advances the flow. + const awaitingModelRef = useRef(null); + const modelCardRefs = useRef>({}); const [micPermissionStatus, setMicPermissionStatus] = useState('undetermined'); const [accessibilityStatus, setAccessibilityStatus] = useState('undetermined'); const [hotkeyName, setHotkeyName] = useState('Right Option'); @@ -90,44 +83,47 @@ export function Onboarding({ onComplete }: OnboardingProps) { GetPlatform().then((p: string) => { setPlatform(p); }); + }, []); - // Set up event listeners for download progress - const progressHandler = (progress: DownloadProgress) => { - setDownloadProgress(progress); - setDownloadError(null); - }; - - const completeHandler = async (data: { model: string }) => { - setDownloadProgress(null); - try { - await SetModel(data.model); - } catch (err) { - setDownloadError(String(err)); - return; - } - // Refresh models list - GetModels().then((modelList: ModelInfo[]) => { - setModels(modelList); + useEffect(() => { + const awaiting = awaitingModelRef.current; + if (!awaiting || downloadState.model !== awaiting || downloadState.status !== 'completed') return; + awaitingModelRef.current = null; + SetModel(awaiting) + .then(() => { + GetModels().then((modelList: ModelInfo[]) => setModels(modelList)); + setStep('micRequest'); + }) + .catch((err) => { + LogError(`[frontend] Activating downloaded model failed: ${err}`); + setDownloadStartError(errorMessage(err)); }); - // Move to mic permission request step - setStep('micRequest'); - }; + }, [downloadState.model, downloadState.status]); - const errorHandler = (data: { model: string; error: string }) => { - setDownloadProgress(null); - setDownloadError(data.error); - }; - - const cleanupProgress = EventsOn('downloadProgress', progressHandler); - const cleanupComplete = EventsOn('downloadComplete', completeHandler); - const cleanupError = EventsOn('downloadError', errorHandler); + const startSelectedDownload = useCallback(async () => { + setDownloadStartError(null); + awaitingModelRef.current = selectedModel; + try { + await DownloadModel(selectedModel); + } catch (err) { + // Already downloading this exact model (e.g. after a remount): keep + // waiting. Ask the backend, since the rendered state may lag behind. + const current = await GetModelDownloadState().catch(() => null) as ModelDownloadState | null; + if (current && isDownloadActive(current) && current.model === selectedModel) return; + awaitingModelRef.current = null; + setDownloadStartError(errorMessage(err)); + } + }, [selectedModel]); - return () => { - cleanupProgress(); - cleanupComplete(); - cleanupError(); - }; - }, []); + const handleCancelDownload = useCallback(async () => { + awaitingModelRef.current = null; + try { + await CancelModelDownload(selectedModel); + } catch (err) { + LogError(`[frontend] Cancel onboarding download failed: ${err}`); + } + setStep('model'); + }, [selectedModel]); const handleProviderSelect = useCallback(async () => { await SetProvider(selectedProvider); @@ -159,17 +155,10 @@ export function Onboarding({ onComplete }: OnboardingProps) { // Model already downloaded, skip to mic permission request setStep('micRequest'); } else { - // Start download setStep('download'); - setDownloadError(null); - await DownloadModel(selectedModel); + await startSelectedDownload(); } - }, [selectedModel, models]); - - const handleRetryDownload = useCallback(async () => { - setDownloadError(null); - await DownloadModel(selectedModel); - }, [selectedModel]); + }, [selectedModel, models, startSelectedDownload]); const handleCheckMicPermission = useCallback(async () => { const status = await CheckMicrophonePermission(); @@ -389,20 +378,47 @@ export function Onboarding({ onComplete }: OnboardingProps) { const renderModelSelection = () => { const recommendedModels = models.filter(m => ['base.en', 'small.en', 'tiny.en'].includes(m.name)); + const focusableModel = recommendedModels.some(m => m.name === selectedModel) + ? selectedModel + : recommendedModels[0]?.name; + + // Standard radio-group keyboard pattern: one Tab stop, arrows move and select. + const handleModelKeyDown = (e: React.KeyboardEvent, index: number) => { + const step = e.key === 'ArrowDown' || e.key === 'ArrowRight' ? 1 : e.key === 'ArrowUp' || e.key === 'ArrowLeft' ? -1 : 0; + if (step !== 0) { + e.preventDefault(); + const next = recommendedModels[(index + step + recommendedModels.length) % recommendedModels.length]; + setSelectedModel(next.name); + modelCardRefs.current[next.name]?.focus(); + } else if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault(); + setSelectedModel(recommendedModels[index].name); + } + }; return (
-

Choose a Model

+

Choose a Model

Select a transcription model. You can always change this later.

-
- {recommendedModels.map(model => ( +
+ {recommendedModels.map((model, index) => { + const idBase = `onboarding-model-${model.name.replace(/[^a-z0-9-]/gi, '-')}`; + const recommended = model.name === 'base.en'; + return (
{ modelCardRefs.current[model.name] = el; }} + role="radio" + aria-checked={selectedModel === model.name} + aria-label={`${fullModelName(model)}${recommended ? ', recommended' : ''}`} + aria-describedby={`${idBase}-size ${idBase}-desc${model.downloaded ? ` ${idBase}-downloaded` : ''}`} + tabIndex={model.name === focusableModel ? 0 : -1} onClick={() => setSelectedModel(model.name)} + onKeyDown={(e) => handleModelKeyDown(e, index)} >
@@ -410,26 +426,31 @@ export function Onboarding({ onComplete }: OnboardingProps) {
- {model.displayName} - {model.name === 'base.en' && Recommended} + {model.displayName} + {model.englishOnly && EN} + {recommended && Recommended} +
+
+ {model.size} + + {model.downloaded && Already downloaded}
- {model.size}
-
+
{model.name === 'tiny.en' && 'Fastest option, great for quick notes and simple dictation'} {model.name === 'base.en' && 'Best balance of speed and accuracy for everyday use'} {model.name === 'small.en' && 'Higher accuracy for detailed transcription, but much slower on CPU'}
- {model.downloaded && ( -
- - - -
- )}
- ))} + ); + })}
@@ -446,7 +467,12 @@ export function Onboarding({ onComplete }: OnboardingProps) { const renderDownload = () => { const model = models.find(m => m.name === selectedModel); + const matching = downloadState.model === selectedModel; + const downloadProgress = matching && isDownloadActive(downloadState) ? downloadState : null; const progress = downloadProgress?.progress || 0; + const downloadError = downloadStartError + || (matching && downloadState.status === 'failed' ? downloadState.error : null) + || (matching && downloadState.status === 'cancelled' ? 'The download was cancelled.' : null); return (
@@ -494,8 +520,8 @@ export function Onboarding({ onComplete }: OnboardingProps) { ) : downloadProgress ? ( <> -

Downloading {model?.displayName}

-

A local model is required before Yap can transcribe offline. {model?.size} - {progress.toFixed(0)}%

+

Downloading {model ? fullModelName(model) : selectedModel}

+

A local model is required before Yap can transcribe offline. {formatBytes(downloadProgress.downloaded)} of {formatBytes(downloadProgress.total || model?.sizeBytes || 0)} - {progress.toFixed(0)}%

) : ( <> @@ -511,10 +537,14 @@ export function Onboarding({ onComplete }: OnboardingProps) { - + ) : downloadProgress ? ( + ) : null}
diff --git a/frontend/src/ProviderLogo.tsx b/frontend/src/ProviderLogo.tsx new file mode 100644 index 0000000..2c85e78 --- /dev/null +++ b/frontend/src/ProviderLogo.tsx @@ -0,0 +1,25 @@ +import type { ModelInfo } from './modelTypes'; + +export type ModelVendor = 'openai'; + +// Every catalog model is currently an OpenAI Whisper model run locally. +export function modelVendor(_model: Pick): ModelVendor { + return 'openai'; +} + +export const VENDOR_NAMES: Record = { + openai: 'OpenAI', +}; + +// OpenAI logo mark (via Simple Icons), unmodified, rendered in currentColor. +const OPENAI_PATH = 'M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z'; + +export function ProviderLogo({ vendor, size = 'small' }: { vendor: ModelVendor; size?: 'small' | 'large' }) { + return ( + + + + ); +} diff --git a/frontend/src/modelTypes.ts b/frontend/src/modelTypes.ts new file mode 100644 index 0000000..9cf0ce1 --- /dev/null +++ b/frontend/src/modelTypes.ts @@ -0,0 +1,85 @@ +export interface ModelInfo { + name: string; + displayName: string; + size: string; + sizeBytes: number; + downloaded: boolean; + englishOnly: boolean; + provider: string; + speed: number; + accuracy: number; + useCase: string; + recommendation: string; + favorite: boolean; +} + +export type ModelDownloadStatus = + | 'idle' + | 'downloading' + | 'cancelling' + | 'completed' + | 'failed' + | 'cancelled'; + +export interface ModelDownloadState { + model: string; + status: ModelDownloadStatus; + downloaded: number; + total: number; + progress: number; + error: string; +} + +export const IDLE_DOWNLOAD: ModelDownloadState = { + model: '', + status: 'idle', + downloaded: 0, + total: 0, + progress: 0, + error: '', +}; + +export function isDownloadActive(state: ModelDownloadState): boolean { + return state.status === 'downloading' || state.status === 'cancelling'; +} + +export function isTerminal(state: ModelDownloadState): boolean { + return state.status === 'completed' || state.status === 'failed' || state.status === 'cancelled'; +} + +export function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return '0 MB'; + if (bytes >= 1e9) return `${(bytes / 1e9).toFixed(2)} GB`; + if (bytes >= 1e6) return `${(bytes / 1e6).toFixed(bytes >= 1e8 ? 0 : 1)} MB`; + return `${Math.max(1, Math.round(bytes / 1e3))} KB`; +} + +// Library rows show the shared display name with an EN badge; elsewhere the +// language must be part of the name to tell variants apart. +export function fullModelName(model: Pick): string { + return model.englishOnly ? `${model.displayName} (English)` : model.displayName; +} + +export function providerLabel(provider: string): string { + return provider === 'local' ? 'OpenAI Whisper' : provider; +} + +export function languageLabel(model: Pick): string { + return model.englishOnly ? 'English only' : 'Multilingual'; +} + +const SPEED_LABELS = ['', 'Slowest', 'Slow', 'Moderate', 'Fast', 'Fastest']; +const ACCURACY_LABELS = ['', 'Basic', 'Good', 'Better', 'High', 'Highest']; + +export function speedLabel(rating: number): string { + return SPEED_LABELS[rating] ?? ''; +} + +export function accuracyLabel(rating: number): string { + return ACCURACY_LABELS[rating] ?? ''; +} + +export function errorMessage(err: unknown): string { + if (err instanceof Error) return err.message; + return String(err); +} diff --git a/frontend/src/test/fixtures.ts b/frontend/src/test/fixtures.ts new file mode 100644 index 0000000..4212a86 --- /dev/null +++ b/frontend/src/test/fixtures.ts @@ -0,0 +1,30 @@ +import { IDLE_DOWNLOAD, ModelDownloadState, ModelInfo } from '../modelTypes'; + +export function model(overrides: Partial & Pick): ModelInfo { + return { + displayName: overrides.name, + size: '148 MB', + sizeBytes: 147964211, + downloaded: false, + englishOnly: overrides.name.endsWith('.en'), + provider: 'local', + speed: 3, + accuracy: 3, + useCase: 'General dictation.', + recommendation: '', + favorite: false, + ...overrides, + }; +} + +export const MODELS: ModelInfo[] = [ + model({ name: 'tiny.en', displayName: 'Whisper Tiny', size: '78 MB', sizeBytes: 77704715, speed: 5, accuracy: 1, recommendation: 'Best for speed', useCase: 'Quick notes on low-power machines.' }), + model({ name: 'base.en', displayName: 'Whisper Base', speed: 4, accuracy: 2, recommendation: 'Recommended', downloaded: true, useCase: 'Everyday English dictation.' }), + model({ name: 'base', displayName: 'Whisper Base', speed: 4, accuracy: 2, downloaded: true, favorite: true, useCase: 'Everyday dictation in many languages.' }), + model({ name: 'small', displayName: 'Whisper Small', size: '488 MB', sizeBytes: 487601967, recommendation: 'Recommended multilingual', useCase: 'Best balance for languages other than English.' }), + model({ name: 'large-v3', displayName: 'Whisper Large v3', size: '3.1 GB', sizeBytes: 3095033483, speed: 1, accuracy: 5, recommendation: 'Most accurate', useCase: 'Maximum accuracy in any language.' }), +]; + +export function downloadState(overrides: Partial): ModelDownloadState { + return { ...IDLE_DOWNLOAD, ...overrides }; +} diff --git a/frontend/src/test/runtimeMock.ts b/frontend/src/test/runtimeMock.ts new file mode 100644 index 0000000..c6fa82a --- /dev/null +++ b/frontend/src/test/runtimeMock.ts @@ -0,0 +1,29 @@ +type Callback = (...data: any[]) => void; + +const listeners = new Map>(); + +export function EventsOn(eventName: string, callback: Callback): () => void { + if (!listeners.has(eventName)) listeners.set(eventName, new Set()); + const wrapped: Callback = (...data) => callback(...data); + listeners.get(eventName)!.add(wrapped); + return () => { + listeners.get(eventName)?.delete(wrapped); + }; +} + +export function EventsEmit(eventName: string, ...data: any[]): void { + listeners.get(eventName)?.forEach((cb) => cb(...data)); +} + +export function listenerCount(eventName: string): number { + return listeners.get(eventName)?.size ?? 0; +} + +export function resetListeners(): void { + listeners.clear(); +} + +export const LogInfo = (_message: string) => {}; +export const LogError = (_message: string) => {}; +export const LogDebug = (_message: string) => {}; +export const LogWarning = (_message: string) => {}; diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts new file mode 100644 index 0000000..1443952 --- /dev/null +++ b/frontend/src/test/setup.ts @@ -0,0 +1,5 @@ +import '@testing-library/jest-dom/vitest'; +import { cleanup } from '@testing-library/react'; +import { afterEach } from 'vitest'; + +afterEach(() => cleanup()); diff --git a/frontend/src/useModelDownload.test.tsx b/frontend/src/useModelDownload.test.tsx new file mode 100644 index 0000000..1eb39fd --- /dev/null +++ b/frontend/src/useModelDownload.test.tsx @@ -0,0 +1,45 @@ +import { StrictMode } from 'react'; +import { act, render, screen } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as Api from '../wailsjs/go/main/App'; +import { EventsEmit, listenerCount, resetListeners } from './test/runtimeMock'; +import { MODEL_DOWNLOAD_EVENT, useModelDownload } from './useModelDownload'; + +vi.mock('../wailsjs/runtime/runtime', async () => await import('./test/runtimeMock')); +vi.mock('../wailsjs/go/main/App', () => ({ GetModelDownloadState: vi.fn() })); + +function Probe() { + const state = useModelDownload(); + return {`${state.model}:${state.status}:${state.progress}`}; +} + +describe('useModelDownload', () => { + beforeEach(() => { + resetListeners(); + vi.mocked(Api.GetModelDownloadState).mockReset(); + }); + + it('recovers existing state and follows events with one listener under StrictMode', async () => { + vi.mocked(Api.GetModelDownloadState).mockResolvedValue({ + model: 'small', status: 'downloading', downloaded: 10, total: 100, progress: 10, error: '', + } as any); + const { unmount } = render(); + expect(await screen.findByText('small:downloading:10')).toBeInTheDocument(); + expect(listenerCount(MODEL_DOWNLOAD_EVENT)).toBe(1); + + act(() => EventsEmit(MODEL_DOWNLOAD_EVENT, { model: 'small', status: 'completed', downloaded: 100, total: 100, progress: 100, error: '' })); + expect(screen.getByTestId('state')).toHaveTextContent('small:completed:100'); + + unmount(); + expect(listenerCount(MODEL_DOWNLOAD_EVENT)).toBe(0); + }); + + it('does not let a slow initial fetch overwrite a newer event', async () => { + let resolve!: (value: any) => void; + vi.mocked(Api.GetModelDownloadState).mockReturnValue(new Promise((r) => { resolve = r; })); + render(); + act(() => EventsEmit(MODEL_DOWNLOAD_EVENT, { model: 'tiny', status: 'downloading', downloaded: 5, total: 10, progress: 50, error: '' })); + await act(async () => resolve({ model: '', status: 'idle', downloaded: 0, total: 0, progress: 0, error: '' })); + expect(screen.getByTestId('state')).toHaveTextContent('tiny:downloading:50'); + }); +}); diff --git a/frontend/src/useModelDownload.ts b/frontend/src/useModelDownload.ts new file mode 100644 index 0000000..ae4940a --- /dev/null +++ b/frontend/src/useModelDownload.ts @@ -0,0 +1,35 @@ +import { useEffect, useState } from 'react'; +import { GetModelDownloadState } from '../wailsjs/go/main/App'; +import { EventsOn, LogError } from '../wailsjs/runtime/runtime'; +import { IDLE_DOWNLOAD, ModelDownloadState } from './modelTypes'; + +export const MODEL_DOWNLOAD_EVENT = 'modelDownloadState'; + +// Tracks the backend's single model download. The initial fetch recovers state +// after navigation or remounting; events keep it current afterwards. +export function useModelDownload(): ModelDownloadState { + const [state, setState] = useState(IDLE_DOWNLOAD); + + useEffect(() => { + let disposed = false; + let receivedEvent = false; + + const unsubscribe = EventsOn(MODEL_DOWNLOAD_EVENT, (next: ModelDownloadState) => { + receivedEvent = true; + if (!disposed) setState(next); + }); + + GetModelDownloadState() + .then((initial) => { + if (!disposed && !receivedEvent) setState(initial as ModelDownloadState); + }) + .catch((err) => LogError(`[frontend] GetModelDownloadState failed: ${err}`)); + + return () => { + disposed = true; + unsubscribe(); + }; + }, []); + + return state; +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 4955065..197574f 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,7 +1,16 @@ +/// import {defineConfig} from 'vite' import react from '@vitejs/plugin-react' // https://vitejs.dev/config/ export default defineConfig({ - plugins: [react()] + // plugin-react's Fast Refresh preamble breaks under Vitest; esbuild's + // automatic JSX runtime is enough for tests. + plugins: process.env.VITEST ? [] : [react()], + esbuild: process.env.VITEST ? { jsx: 'automatic' } : undefined, + test: { + environment: 'jsdom', + setupFiles: ['./src/test/setup.ts'], + css: false, + }, }) diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 787a39a..72ae94b 100755 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -3,6 +3,8 @@ import {main} from '../models'; import {models} from '../models'; +export function CancelModelDownload(arg1:string):Promise; + export function CancelRecording():Promise; export function CheckAccessibilityPermission():Promise; @@ -15,6 +17,8 @@ export function CopyHistoryItem(arg1:string):Promise; export function DeleteHistoryItem(arg1:string):Promise; +export function DeleteModel(arg1:string):Promise; + export function DownloadModel(arg1:string):Promise; export function GetAudioData(arg1:string):Promise; @@ -33,6 +37,8 @@ export function GetHistory():Promise>; export function GetLogsDir():Promise; +export function GetModelDownloadState():Promise; + export function GetModels():Promise>; export function GetPlatform():Promise; @@ -71,6 +77,8 @@ export function SetCancelHotkey(arg1:string):Promise; export function SetModel(arg1:string):Promise; +export function SetModelFavorite(arg1:string,arg2:boolean):Promise; + export function SetOnboardingCompleted(arg1:boolean):Promise; export function SetOpenAIKey(arg1:string):Promise; @@ -92,4 +100,5 @@ export function StartRecording():Promise; export function StopRecording():Promise; export function ToggleRecording():Promise; -export function WaitForStartup(): Promise; + +export function WaitForStartup():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index b96e01b..eb9712c 100755 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -2,6 +2,10 @@ // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // This file is automatically generated. DO NOT EDIT +export function CancelModelDownload(arg1) { + return window['go']['main']['App']['CancelModelDownload'](arg1); +} + export function CancelRecording() { return window['go']['main']['App']['CancelRecording'](); } @@ -26,6 +30,10 @@ export function DeleteHistoryItem(arg1) { return window['go']['main']['App']['DeleteHistoryItem'](arg1); } +export function DeleteModel(arg1) { + return window['go']['main']['App']['DeleteModel'](arg1); +} + export function DownloadModel(arg1) { return window['go']['main']['App']['DownloadModel'](arg1); } @@ -62,6 +70,10 @@ export function GetLogsDir() { return window['go']['main']['App']['GetLogsDir'](); } +export function GetModelDownloadState() { + return window['go']['main']['App']['GetModelDownloadState'](); +} + export function GetModels() { return window['go']['main']['App']['GetModels'](); } @@ -138,6 +150,10 @@ export function SetModel(arg1) { return window['go']['main']['App']['SetModel'](arg1); } +export function SetModelFavorite(arg1, arg2) { + return window['go']['main']['App']['SetModelFavorite'](arg1, arg2); +} + export function SetOnboardingCompleted(arg1) { return window['go']['main']['App']['SetOnboardingCompleted'](arg1); } @@ -181,6 +197,7 @@ export function StopRecording() { export function ToggleRecording() { return window['go']['main']['App']['ToggleRecording'](); } + export function WaitForStartup() { return window['go']['main']['App']['WaitForStartup'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 66e2a0f..374cfeb 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -66,8 +66,15 @@ export namespace main { name: string; displayName: string; size: string; + sizeBytes: number; downloaded: boolean; englishOnly: boolean; + provider: string; + speed: number; + accuracy: number; + useCase: string; + recommendation: string; + favorite: boolean; static createFrom(source: any = {}) { return new ModelInfo(source); @@ -78,8 +85,15 @@ export namespace main { this.name = source["name"]; this.displayName = source["displayName"]; this.size = source["size"]; + this.sizeBytes = source["sizeBytes"]; this.downloaded = source["downloaded"]; this.englishOnly = source["englishOnly"]; + this.provider = source["provider"]; + this.speed = source["speed"]; + this.accuracy = source["accuracy"]; + this.useCase = source["useCase"]; + this.recommendation = source["recommendation"]; + this.favorite = source["favorite"]; } } export class UsageStats { @@ -120,6 +134,7 @@ export namespace models { cancelHotkey: string; soundEnabled?: boolean; onboardingCompleted: boolean; + favoriteModels?: string[]; static createFrom(source: any = {}) { return new Config(source); @@ -137,6 +152,29 @@ export namespace models { this.cancelHotkey = source["cancelHotkey"]; this.soundEnabled = source["soundEnabled"]; this.onboardingCompleted = source["onboardingCompleted"]; + this.favoriteModels = source["favoriteModels"]; + } + } + export class DownloadState { + model: string; + status: string; + downloaded: number; + total: number; + progress: number; + error: string; + + static createFrom(source: any = {}) { + return new DownloadState(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.model = source["model"]; + this.status = source["status"]; + this.downloaded = source["downloaded"]; + this.total = source["total"]; + this.progress = source["progress"]; + this.error = source["error"]; } } diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 49e7aa9..f4302c2 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -8,6 +8,8 @@ import ( goruntime "runtime" "sync" "time" + + "yap/internal/paths" ) const retentionDays = 7 @@ -37,12 +39,12 @@ func Init() error { // New creates a file logger under the user's Yap config directory. func New() (*FileLogger, error) { - configDir, err := os.UserConfigDir() + dataDir, err := paths.AppDataDir() if err != nil { return nil, err } - logsDir := filepath.Join(configDir, "yap", "logs") + logsDir := filepath.Join(dataDir, "logs") if err := os.MkdirAll(logsDir, 0755); err != nil { return nil, err } @@ -74,11 +76,11 @@ func GetLogsDir() string { if defaultLogger != nil { return defaultLogger.logsDir } - configDir, err := os.UserConfigDir() + dataDir, err := paths.AppDataDir() if err != nil { return "" } - return filepath.Join(configDir, "yap", "logs") + return filepath.Join(dataDir, "logs") } // Close flushes the shutdown marker and closes the active log file. diff --git a/internal/models/catalog.go b/internal/models/catalog.go new file mode 100644 index 0000000..3cab2b4 --- /dev/null +++ b/internal/models/catalog.go @@ -0,0 +1,132 @@ +package models + +import ( + "fmt" + "os" + "strings" +) + +const ( + // BaseURL is the base URL for downloading models + BaseURL = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main" + + // ModelBaseURLEnv overrides BaseURL, e.g. to point development builds at a + // local mirror for exercising download failures. + ModelBaseURLEnv = "YAP_MODEL_BASE_URL" + + // ProviderLocal identifies models run on-device by whisper.cpp. + ProviderLocal = "local" +) + +// ModelSpec is the canonical description of a supported transcription model. +// Speed and Accuracy are curated relative ratings from 1 (lowest) to 5. +// DisplayName is shared by a model's English-only and multilingual variants; +// EnglishOnly distinguishes them. +type ModelSpec struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` + Filename string `json:"filename"` + SizeBytes int64 `json:"sizeBytes"` + Size string `json:"size"` + EnglishOnly bool `json:"englishOnly"` + Provider string `json:"provider"` + Speed int `json:"speed"` + Accuracy int `json:"accuracy"` + UseCase string `json:"useCase"` + Recommendation string `json:"recommendation"` +} + +var catalog = []ModelSpec{ + { + Name: "tiny.en", DisplayName: "Whisper Tiny", Filename: "ggml-tiny.en.bin", + SizeBytes: 77704715, Size: "78 MB", EnglishOnly: true, Provider: ProviderLocal, + Speed: 5, Accuracy: 1, + UseCase: "Quick notes on older or low-power machines where speed matters most.", + Recommendation: "Best for speed", + }, + { + Name: "tiny", DisplayName: "Whisper Tiny", Filename: "ggml-tiny.bin", + SizeBytes: 77691713, Size: "78 MB", EnglishOnly: false, Provider: ProviderLocal, + Speed: 5, Accuracy: 1, + UseCase: "Fast rough drafts in any language on low-power machines.", + }, + { + Name: "base.en", DisplayName: "Whisper Base", Filename: "ggml-base.en.bin", + SizeBytes: 147964211, Size: "148 MB", EnglishOnly: true, Provider: ProviderLocal, + Speed: 4, Accuracy: 2, + UseCase: "Everyday English dictation with a good balance of speed and accuracy.", + Recommendation: "Recommended", + }, + { + Name: "base", DisplayName: "Whisper Base", Filename: "ggml-base.bin", + SizeBytes: 147951465, Size: "148 MB", EnglishOnly: false, Provider: ProviderLocal, + Speed: 4, Accuracy: 2, + UseCase: "Everyday dictation in many languages with modest hardware needs.", + }, + { + Name: "small.en", DisplayName: "Whisper Small", Filename: "ggml-small.en.bin", + SizeBytes: 487614201, Size: "488 MB", EnglishOnly: true, Provider: ProviderLocal, + Speed: 3, Accuracy: 3, + UseCase: "Longer English dictation where accuracy matters more than speed.", + Recommendation: "Better accuracy", + }, + { + Name: "small", DisplayName: "Whisper Small", Filename: "ggml-small.bin", + SizeBytes: 487601967, Size: "488 MB", EnglishOnly: false, Provider: ProviderLocal, + Speed: 3, Accuracy: 3, + UseCase: "The best everyday balance for languages other than English.", + Recommendation: "Recommended multilingual", + }, + { + Name: "medium.en", DisplayName: "Whisper Medium", Filename: "ggml-medium.en.bin", + SizeBytes: 1533774781, Size: "1.5 GB", EnglishOnly: true, Provider: ProviderLocal, + Speed: 2, Accuracy: 4, + UseCase: "High-accuracy English on fast machines with plenty of memory.", + }, + { + Name: "medium", DisplayName: "Whisper Medium", Filename: "ggml-medium.bin", + SizeBytes: 1533763059, Size: "1.5 GB", EnglishOnly: false, Provider: ProviderLocal, + Speed: 2, Accuracy: 4, + UseCase: "High-accuracy multilingual transcription on fast machines.", + }, + { + Name: "large-v3", DisplayName: "Whisper Large v3", Filename: "ggml-large-v3.bin", + SizeBytes: 3095033483, Size: "3.1 GB", EnglishOnly: false, Provider: ProviderLocal, + Speed: 1, Accuracy: 5, + UseCase: "Maximum accuracy in any language; slow and memory hungry.", + Recommendation: "Most accurate", + }, +} + +// Catalog returns every supported model, ordered from fastest to most accurate. +func Catalog() []ModelSpec { + out := make([]ModelSpec, len(catalog)) + copy(out, catalog) + return out +} + +// LookupModel returns the catalog entry for name. +func LookupModel(name string) (ModelSpec, bool) { + for _, spec := range catalog { + if spec.Name == name { + return spec, true + } + } + return ModelSpec{}, false +} + +// RequireModel returns the catalog entry for name or an unknown-model error. +func RequireModel(name string) (ModelSpec, error) { + spec, ok := LookupModel(name) + if !ok { + return ModelSpec{}, fmt.Errorf("%w: %q", ErrUnknownModel, name) + } + return spec, nil +} + +func defaultBaseURL() string { + if override := strings.TrimRight(os.Getenv(ModelBaseURLEnv), "/"); override != "" { + return override + } + return BaseURL +} diff --git a/internal/models/config.go b/internal/models/config.go index c329d00..4dc2fbc 100644 --- a/internal/models/config.go +++ b/internal/models/config.go @@ -5,6 +5,9 @@ import ( "os" "path/filepath" "runtime" + "sync" + + "yap/internal/paths" ) // Config holds application settings @@ -40,6 +43,9 @@ type Config struct { // OnboardingCompleted indicates whether the user has completed the onboarding flow OnboardingCompleted bool `json:"onboardingCompleted"` + + // FavoriteModels lists catalog model names the user starred in the library + FavoriteModels []string `json:"favoriteModels,omitempty"` } // DefaultConfig returns the default configuration @@ -72,18 +78,18 @@ func DefaultRecordingHotkey() string { type ConfigManager struct { configDir string configFile string - config *Config + + mu sync.RWMutex + config *Config } // NewConfigManager creates a new config manager func NewConfigManager() (*ConfigManager, error) { - // Get user config directory - configDir, err := os.UserConfigDir() + appConfigDir, err := paths.AppDataDir() if err != nil { return nil, err } - appConfigDir := filepath.Join(configDir, "yap") if err := os.MkdirAll(appConfigDir, 0755); err != nil { return nil, err } @@ -133,79 +139,145 @@ func (cm *ConfigManager) Load() error { // Save writes configuration to file func (cm *ConfigManager) Save() error { + cm.mu.Lock() + defer cm.mu.Unlock() + return cm.saveLocked() +} + +// saveLocked writes the config atomically: a crash or a concurrent reader +// never sees a partially written config.json. Callers hold cm.mu. +func (cm *ConfigManager) saveLocked() error { data, err := json.MarshalIndent(cm.config, "", " ") if err != nil { return err } - return os.WriteFile(cm.configFile, data, 0644) + tmp, err := os.CreateTemp(cm.configDir, "config-*.json.tmp") + if err != nil { + return err + } + tmpPath := tmp.Name() + if _, err := tmp.Write(data); err != nil { + tmp.Close() + os.Remove(tmpPath) + return err + } + if err := tmp.Close(); err != nil { + os.Remove(tmpPath) + return err + } + if err := os.Chmod(tmpPath, 0644); err != nil { + os.Remove(tmpPath) + return err + } + if err := os.Rename(tmpPath, cm.configFile); err != nil { + os.Remove(tmpPath) + return err + } + return nil } -// Get returns the current configuration +// update applies change and saves while holding the lock, so concurrent +// setters cannot lose each other's writes. +func (cm *ConfigManager) update(change func(*Config)) error { + cm.mu.Lock() + defer cm.mu.Unlock() + change(cm.config) + return cm.saveLocked() +} + +// Get returns a snapshot of the current configuration func (cm *ConfigManager) Get() *Config { - return cm.config + cm.mu.RLock() + defer cm.mu.RUnlock() + snapshot := *cm.config + snapshot.FavoriteModels = append([]string(nil), cm.config.FavoriteModels...) + return &snapshot } -// Update updates the configuration +// Update replaces the configuration func (cm *ConfigManager) Update(config *Config) error { + cm.mu.Lock() + defer cm.mu.Unlock() cm.config = config - return cm.Save() + return cm.saveLocked() } // SetProvider updates the provider setting func (cm *ConfigManager) SetProvider(provider string) error { - cm.config.Provider = provider - return cm.Save() + return cm.update(func(c *Config) { c.Provider = provider }) } // SetModel updates the model setting func (cm *ConfigManager) SetModel(model string) error { - cm.config.Model = model - return cm.Save() + return cm.update(func(c *Config) { c.Model = model }) } // SetOpenAIAPIKey updates the API key func (cm *ConfigManager) SetOpenAIAPIKey(key string) error { - cm.config.OpenAIAPIKey = key - return cm.Save() + return cm.update(func(c *Config) { c.OpenAIAPIKey = key }) } // SetAutoPaste updates the auto-paste setting func (cm *ConfigManager) SetAutoPaste(enabled bool) error { - cm.config.AutoPaste = enabled - return cm.Save() + return cm.update(func(c *Config) { c.AutoPaste = enabled }) } // SetAudioInputDevice updates the audio input device setting func (cm *ConfigManager) SetAudioInputDevice(deviceName string) error { - cm.config.AudioInputDevice = deviceName - return cm.Save() + return cm.update(func(c *Config) { c.AudioInputDevice = deviceName }) } // SetRecordingHotkey updates the recording hotkey setting func (cm *ConfigManager) SetRecordingHotkey(hotkey string) error { - cm.config.RecordingHotkey = hotkey - return cm.Save() + return cm.update(func(c *Config) { c.RecordingHotkey = hotkey }) } // SetCancelHotkey updates the cancel hotkey setting func (cm *ConfigManager) SetCancelHotkey(hotkey string) error { - cm.config.CancelHotkey = hotkey - return cm.Save() + return cm.update(func(c *Config) { c.CancelHotkey = hotkey }) } // SetSoundEnabled updates the sound enabled setting func (cm *ConfigManager) SetSoundEnabled(enabled bool) error { - cm.config.SoundEnabled = &enabled - return cm.Save() + return cm.update(func(c *Config) { c.SoundEnabled = &enabled }) } // IsOnboardingCompleted returns whether onboarding has been completed func (cm *ConfigManager) IsOnboardingCompleted() bool { + cm.mu.RLock() + defer cm.mu.RUnlock() return cm.config.OnboardingCompleted } // SetOnboardingCompleted marks onboarding as completed func (cm *ConfigManager) SetOnboardingCompleted(completed bool) error { - cm.config.OnboardingCompleted = completed - return cm.Save() + return cm.update(func(c *Config) { c.OnboardingCompleted = completed }) +} + +// IsFavoriteModel reports whether the user starred model +func (cm *ConfigManager) IsFavoriteModel(model string) bool { + cm.mu.RLock() + defer cm.mu.RUnlock() + for _, name := range cm.config.FavoriteModels { + if name == model { + return true + } + } + return false +} + +// SetModelFavorite stars or unstars model +func (cm *ConfigManager) SetModelFavorite(model string, favorite bool) error { + return cm.update(func(c *Config) { + kept := make([]string, 0, len(c.FavoriteModels)+1) + for _, name := range c.FavoriteModels { + if name != model { + kept = append(kept, name) + } + } + if favorite { + kept = append(kept, model) + } + c.FavoriteModels = kept + }) } diff --git a/internal/models/config_test.go b/internal/models/config_test.go new file mode 100644 index 0000000..94de10a --- /dev/null +++ b/internal/models/config_test.go @@ -0,0 +1,103 @@ +package models + +import ( + "path/filepath" + "reflect" + "sort" + "sync" + "testing" + + "yap/internal/paths" +) + +func TestModelFavoritesPersist(t *testing.T) { + t.Setenv(paths.ConfigDirEnv, t.TempDir()) + cm, err := NewConfigManager() + if err != nil { + t.Fatal(err) + } + if cm.IsFavoriteModel("small") { + t.Fatal("no favorites by default") + } + for _, step := range []struct { + model string + favorite bool + }{{"small", true}, {"tiny.en", true}, {"small", true}, {"tiny.en", false}} { + if err := cm.SetModelFavorite(step.model, step.favorite); err != nil { + t.Fatal(err) + } + } + + reloaded, err := NewConfigManager() + if err != nil { + t.Fatal(err) + } + if got := reloaded.Get().FavoriteModels; !reflect.DeepEqual(got, []string{"small"}) { + t.Fatalf("FavoriteModels = %v, want [small]", got) + } + if !reloaded.IsFavoriteModel("small") || reloaded.IsFavoriteModel("tiny.en") { + t.Fatal("favorites did not round-trip") + } +} + +func TestConcurrentConfigWritesKeepEveryChange(t *testing.T) { + dir := t.TempDir() + t.Setenv(paths.ConfigDirEnv, dir) + cm, err := NewConfigManager() + if err != nil { + t.Fatal(err) + } + names := []string{"tiny", "tiny.en", "base", "base.en", "small", "small.en", "medium", "medium.en", "large-v3"} + var wg sync.WaitGroup + for _, name := range names { + wg.Add(2) + go func(name string) { + defer wg.Done() + if err := cm.SetModelFavorite(name, true); err != nil { + t.Error(err) + } + }(name) + go func(name string) { + defer wg.Done() + _ = cm.IsFavoriteModel(name) + _ = cm.Get().FavoriteModels + if err := cm.SetAutoPaste(name != "tiny"); err != nil { + t.Error(err) + } + }(name) + } + wg.Wait() + + reloaded, err := NewConfigManager() + if err != nil { + t.Fatal(err) + } + got := append([]string(nil), reloaded.Get().FavoriteModels...) + sort.Strings(got) + want := append([]string(nil), names...) + sort.Strings(want) + if !reflect.DeepEqual(got, want) { + t.Fatalf("favorites after concurrent saves = %v, want %v", got, want) + } + matches, _ := filepath.Glob(filepath.Join(dir, "*.tmp")) + if len(matches) != 0 { + t.Fatalf("temporary config files left behind: %v", matches) + } +} + +func TestGetReturnsSnapshot(t *testing.T) { + t.Setenv(paths.ConfigDirEnv, t.TempDir()) + cm, err := NewConfigManager() + if err != nil { + t.Fatal(err) + } + if err := cm.SetModelFavorite("small", true); err != nil { + t.Fatal(err) + } + snapshot := cm.Get() + snapshot.Model = "changed" + snapshot.FavoriteModels[0] = "changed" + if cm.Get().Model == "changed" || !cm.IsFavoriteModel("small") { + t.Fatal("mutating a snapshot must not change the stored config") + } +} diff --git a/internal/models/downloads.go b/internal/models/downloads.go new file mode 100644 index 0000000..26ecc6a --- /dev/null +++ b/internal/models/downloads.go @@ -0,0 +1,271 @@ +package models + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "yap/internal/crashlog" +) + +// DownloadStatus describes where the model download lifecycle currently is. +type DownloadStatus string + +const ( + DownloadIdle DownloadStatus = "idle" + DownloadDownloading DownloadStatus = "downloading" + DownloadCancelling DownloadStatus = "cancelling" + DownloadCompleted DownloadStatus = "completed" + DownloadFailed DownloadStatus = "failed" + DownloadCancelled DownloadStatus = "cancelled" +) + +// DownloadState is the single payload describing model download progress. +// Terminal states remain until the next download starts so a remounted UI can +// still show completion or offer a retry. +type DownloadState struct { + Model string `json:"model"` + Status DownloadStatus `json:"status"` + Downloaded int64 `json:"downloaded"` + Total int64 `json:"total"` + Progress float64 `json:"progress"` + Error string `json:"error"` +} + +// Active reports whether a download is still running. +func (s DownloadState) Active() bool { + return s.Status == DownloadDownloading || s.Status == DownloadCancelling +} + +var ( + ErrDownloadBusy = errors.New("another model is already downloading") + ErrNoMatchingDownload = errors.New("that model is not downloading") + ErrModelActive = errors.New("the active model cannot be deleted") + ErrModelDownloading = errors.New("a model cannot be deleted while it is downloading") + ErrDownloaderClosed = errors.New("downloads are unavailable while Yap is shutting down") +) + +// DownloadFunc performs one model download. +type DownloadFunc func(ctx context.Context, model string, progress DownloadProgress) error + +// Downloader owns the single active model download and its observable state. +type Downloader struct { + download DownloadFunc + remove func(model string) error + emit func(DownloadState) + onDone func(DownloadState) + + // ProgressInterval throttles progress events; initial and final + // states are always emitted. + ProgressInterval time.Duration + + // emitMu serializes "change state, then emit" so events reach the + // frontend in the order the state changed. Lock order: emitMu, then mu. + // emit must never call back into the Downloader. + emitMu sync.Mutex + + mu sync.Mutex + state DownloadState + cancel context.CancelFunc + done chan struct{} + lastEmit time.Time + closed bool +} + +// NewDownloader wires a downloader to the manager. emit receives every +// state change; onDone runs after a download reaches a terminal state. +func NewDownloader(manager *Manager, emit func(DownloadState), onDone func(DownloadState)) *Downloader { + return newDownloader(manager.DownloadModel, manager.DeleteModel, emit, onDone) +} + +func newDownloader(download DownloadFunc, remove func(string) error, emit func(DownloadState), onDone func(DownloadState)) *Downloader { + if emit == nil { + emit = func(DownloadState) {} + } + if onDone == nil { + onDone = func(DownloadState) {} + } + return &Downloader{ + download: download, + remove: remove, + emit: emit, + onDone: onDone, + ProgressInterval: 150 * time.Millisecond, + state: DownloadState{Status: DownloadIdle}, + } +} + +// State returns the current download state. +func (d *Downloader) State() DownloadState { + d.mu.Lock() + defer d.mu.Unlock() + return d.state +} + +// Start validates the request and begins downloading model in the background. +func (d *Downloader) Start(model string) error { + spec, err := RequireModel(model) + if err != nil { + return err + } + + d.emitMu.Lock() + defer d.emitMu.Unlock() + + d.mu.Lock() + if d.closed { + d.mu.Unlock() + return ErrDownloaderClosed + } + if d.state.Active() { + active := d.state.Model + d.mu.Unlock() + return fmt.Errorf("%w: %s", ErrDownloadBusy, active) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + d.cancel = cancel + d.done = done + d.state = DownloadState{Model: model, Status: DownloadDownloading, Total: spec.SizeBytes} + d.lastEmit = time.Now() + initial := d.state + d.mu.Unlock() + + d.emit(initial) + go d.run(ctx, cancel, done, model) + return nil +} + +func (d *Downloader) run(ctx context.Context, cancel context.CancelFunc, done chan struct{}, model string) { + defer crashlog.LogPanic("model download") + defer close(done) + defer cancel() + + err := d.download(ctx, model, func(downloaded, total int64) { + d.emitMu.Lock() + defer d.emitMu.Unlock() + d.mu.Lock() + if d.state.Model != model || !d.state.Active() { + d.mu.Unlock() + return + } + d.state.Downloaded = downloaded + d.state.Total = total + if total > 0 { + d.state.Progress = float64(downloaded) / float64(total) * 100 + } + now := time.Now() + shouldEmit := d.state.Status == DownloadDownloading && + (downloaded == total || now.Sub(d.lastEmit) >= d.ProgressInterval) + if shouldEmit { + d.lastEmit = now + } + snapshot := d.state + d.mu.Unlock() + if shouldEmit { + d.emit(snapshot) + } + }) + + d.emitMu.Lock() + d.mu.Lock() + switch { + case err == nil: + d.state.Status = DownloadCompleted + d.state.Downloaded = d.state.Total + d.state.Progress = 100 + d.state.Error = "" + case ctx.Err() != nil: + d.state.Status = DownloadCancelled + d.state.Error = "" + default: + d.state.Status = DownloadFailed + d.state.Error = err.Error() + } + d.cancel = nil + final := d.state + closed := d.closed + d.mu.Unlock() + + if !closed { + d.emit(final) + } + d.emitMu.Unlock() + if !closed { + d.onDone(final) + } +} + +// Cancel stops the active download if it is downloading model. +func (d *Downloader) Cancel(model string) error { + d.emitMu.Lock() + d.mu.Lock() + if !d.state.Active() || d.state.Model != model { + d.mu.Unlock() + d.emitMu.Unlock() + return fmt.Errorf("%w: %s", ErrNoMatchingDownload, model) + } + if d.state.Status == DownloadCancelling { + d.mu.Unlock() + d.emitMu.Unlock() + return nil + } + d.state.Status = DownloadCancelling + cancel := d.cancel + snapshot := d.state + d.mu.Unlock() + + d.emit(snapshot) + d.emitMu.Unlock() + if cancel != nil { + cancel() + } + return nil +} + +// Delete removes a downloaded model unless it is active or downloading. +func (d *Downloader) Delete(model, activeModel string) error { + if _, err := RequireModel(model); err != nil { + return err + } + if model == activeModel { + return ErrModelActive + } + d.mu.Lock() + downloading := d.state.Active() && d.state.Model == model + d.mu.Unlock() + if downloading { + return ErrModelDownloading + } + if err := d.remove(model); err != nil { + return err + } + d.mu.Lock() + if d.state.Model == model && !d.state.Active() { + d.state = DownloadState{Status: DownloadIdle} + } + d.mu.Unlock() + return nil +} + +// Shutdown cancels any active download and waits up to timeout for it to +// clean up. Later Start calls are rejected. +func (d *Downloader) Shutdown(timeout time.Duration) { + d.mu.Lock() + d.closed = true + cancel := d.cancel + done := d.done + d.mu.Unlock() + + if cancel != nil { + cancel() + } + if done != nil { + select { + case <-done: + case <-time.After(timeout): + } + } +} diff --git a/internal/models/downloads_test.go b/internal/models/downloads_test.go new file mode 100644 index 0000000..414feac --- /dev/null +++ b/internal/models/downloads_test.go @@ -0,0 +1,333 @@ +package models + +import ( + "context" + "errors" + "sync" + "testing" + "time" +) + +type fakeDownload struct { + mu sync.Mutex + started chan string + release chan error + progress DownloadProgress + removed []string +} + +func newFakeDownload() *fakeDownload { + return &fakeDownload{started: make(chan string, 4), release: make(chan error, 4)} +} + +func (f *fakeDownload) download(ctx context.Context, model string, progress DownloadProgress) error { + f.mu.Lock() + f.progress = progress + f.mu.Unlock() + f.started <- model + select { + case err := <-f.release: + return err + case <-ctx.Done(): + return ctx.Err() + } +} + +func (f *fakeDownload) report(downloaded, total int64) { + f.mu.Lock() + p := f.progress + f.mu.Unlock() + p(downloaded, total) +} + +func (f *fakeDownload) remove(model string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.removed = append(f.removed, model) + return nil +} + +type recorder struct { + mu sync.Mutex + states []DownloadState + done chan DownloadState +} + +func newRecorder() *recorder { + return &recorder{done: make(chan DownloadState, 4)} +} + +func (r *recorder) emit(s DownloadState) { + r.mu.Lock() + defer r.mu.Unlock() + r.states = append(r.states, s) +} + +func (r *recorder) onDone(s DownloadState) { r.done <- s } + +func (r *recorder) statuses() []DownloadStatus { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]DownloadStatus, len(r.states)) + for i, s := range r.states { + out[i] = s.Status + } + return out +} + +func (r *recorder) count() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.states) +} + +func waitDone(t *testing.T, r *recorder) DownloadState { + t.Helper() + select { + case s := <-r.done: + return s + case <-time.After(5 * time.Second): + t.Fatal("download did not finish") + return DownloadState{} + } +} + +func setup(t *testing.T) (*Downloader, *fakeDownload, *recorder) { + t.Helper() + f := newFakeDownload() + r := newRecorder() + d := newDownloader(f.download, f.remove, r.emit, r.onDone) + t.Cleanup(func() { d.Shutdown(time.Second) }) + return d, f, r +} + +func TestDownloaderInitialStateIsIdle(t *testing.T) { + d, _, _ := setup(t) + if s := d.State(); s.Status != DownloadIdle || s.Model != "" { + t.Fatalf("unexpected initial state %+v", s) + } +} + +func TestDownloaderCompletesWithoutActivation(t *testing.T) { + d, f, r := setup(t) + if err := d.Start("small"); err != nil { + t.Fatal(err) + } + if got := <-f.started; got != "small" { + t.Fatalf("started %s", got) + } + if s := d.State(); s.Status != DownloadDownloading || s.Model != "small" || s.Total == 0 { + t.Fatalf("unexpected state %+v", s) + } + f.report(50, 100) + f.release <- nil + final := waitDone(t, r) + if final.Status != DownloadCompleted || final.Model != "small" || final.Progress != 100 { + t.Fatalf("unexpected final state %+v", final) + } + if d.State() != final { + t.Fatal("terminal state should remain observable") + } + statuses := r.statuses() + if statuses[0] != DownloadDownloading || statuses[len(statuses)-1] != DownloadCompleted { + t.Fatalf("unexpected transitions %v", statuses) + } +} + +func TestDownloaderEnforcesSingleDownload(t *testing.T) { + d, f, r := setup(t) + if err := d.Start("tiny"); err != nil { + t.Fatal(err) + } + <-f.started + if err := d.Start("base"); !errors.Is(err, ErrDownloadBusy) { + t.Fatalf("expected busy error, got %v", err) + } + if err := d.Start("tiny"); !errors.Is(err, ErrDownloadBusy) { + t.Fatalf("expected busy error for same model, got %v", err) + } + f.release <- nil + waitDone(t, r) + if err := d.Start("base"); err != nil { + t.Fatalf("a new download should start after completion: %v", err) + } + <-f.started + if s := d.State(); s.Model != "base" || s.Status != DownloadDownloading { + t.Fatalf("new download should replace terminal state, got %+v", s) + } +} + +func TestDownloaderRejectsUnknownModel(t *testing.T) { + d, _, r := setup(t) + if err := d.Start("huge"); !errors.Is(err, ErrUnknownModel) { + t.Fatalf("expected unknown model, got %v", err) + } + if r.count() != 0 { + t.Fatal("no state should be emitted for invalid requests") + } +} + +func TestDownloaderCancellationMustMatchModel(t *testing.T) { + d, f, r := setup(t) + if err := d.Cancel("tiny"); !errors.Is(err, ErrNoMatchingDownload) { + t.Fatalf("expected no matching download, got %v", err) + } + d.Start("tiny") + <-f.started + if err := d.Cancel("base"); !errors.Is(err, ErrNoMatchingDownload) { + t.Fatalf("expected mismatch error, got %v", err) + } + if err := d.Cancel("tiny"); err != nil { + t.Fatal(err) + } + final := waitDone(t, r) + if final.Status != DownloadCancelled || final.Error != "" { + t.Fatalf("unexpected final state %+v", final) + } + statuses := r.statuses() + want := []DownloadStatus{DownloadDownloading, DownloadCancelling, DownloadCancelled} + if len(statuses) != len(want) { + t.Fatalf("transitions %v, want %v", statuses, want) + } + for i := range want { + if statuses[i] != want[i] { + t.Fatalf("transitions %v, want %v", statuses, want) + } + } + if err := d.Start("tiny"); err != nil { + t.Fatalf("retry after cancel failed: %v", err) + } + <-f.started +} + +func TestDownloaderFailureIsRecoverable(t *testing.T) { + d, f, r := setup(t) + d.Start("base.en") + <-f.started + f.release <- errors.New("server returned HTTP 503") + final := waitDone(t, r) + if final.Status != DownloadFailed || final.Error != "server returned HTTP 503" { + t.Fatalf("unexpected final state %+v", final) + } + if err := d.Start("base.en"); err != nil { + t.Fatalf("retry failed: %v", err) + } + <-f.started +} + +func TestDownloaderThrottlesProgress(t *testing.T) { + d, f, r := setup(t) + d.ProgressInterval = time.Hour + d.Start("tiny") + <-f.started + for i := int64(1); i < 1000; i++ { + f.report(i, 1000) + } + f.report(1000, 1000) + f.release <- nil + waitDone(t, r) + // initial + final-progress + completed + if n := r.count(); n != 3 { + t.Fatalf("expected 3 emitted states, got %d (%v)", n, r.statuses()) + } + if s := d.State(); s.Downloaded != s.Total { + t.Fatalf("state should track latest progress: %+v", s) + } +} + +func TestDownloaderDeleteProtections(t *testing.T) { + d, f, r := setup(t) + if err := d.Delete("base.en", "base.en"); !errors.Is(err, ErrModelActive) { + t.Fatalf("expected active model protection, got %v", err) + } + if err := d.Delete("nope", "base.en"); !errors.Is(err, ErrUnknownModel) { + t.Fatalf("expected unknown model, got %v", err) + } + d.Start("small") + <-f.started + if err := d.Delete("small", "base.en"); !errors.Is(err, ErrModelDownloading) { + t.Fatalf("expected downloading protection, got %v", err) + } + if err := d.Delete("tiny", "base.en"); err != nil { + t.Fatalf("deleting another inactive model should work during a download: %v", err) + } + f.release <- nil + waitDone(t, r) + if err := d.Delete("small", "base.en"); err != nil { + t.Fatal(err) + } + if s := d.State(); s.Status != DownloadIdle { + t.Fatalf("deleting the completed model should reset state, got %+v", s) + } + if len(f.removed) != 2 { + t.Fatalf("removed %v", f.removed) + } +} + +func TestDownloaderShutdownCancelsAndWaits(t *testing.T) { + f := newFakeDownload() + r := newRecorder() + d := newDownloader(f.download, f.remove, r.emit, r.onDone) + d.Start("medium") + <-f.started + + finished := make(chan struct{}) + go func() { + d.Shutdown(5 * time.Second) + close(finished) + }() + select { + case <-finished: + case <-time.After(5 * time.Second): + t.Fatal("shutdown did not return") + } + if s := d.State(); s.Status != DownloadCancelled { + t.Fatalf("expected cancelled after shutdown, got %+v", s) + } + if err := d.Start("tiny"); !errors.Is(err, ErrDownloaderClosed) { + t.Fatalf("expected closed error, got %v", err) + } +} + +func TestDownloaderEmitsInStateOrderWhenCancelRacesCompletion(t *testing.T) { + var d *Downloader + var mu sync.Mutex + var emitted []DownloadStatus + done := make(chan DownloadState, 1) + emit := func(s DownloadState) { + if s.Status == DownloadCancelling { + // Widen the window between changing state and delivering the event. + time.Sleep(50 * time.Millisecond) + } + mu.Lock() + emitted = append(emitted, s.Status) + mu.Unlock() + } + // The transfer finishes successfully the moment cancellation begins. + download := func(ctx context.Context, model string, progress DownloadProgress) error { + for d.State().Status != DownloadCancelling { + time.Sleep(time.Millisecond) + } + return nil + } + d = newDownloader(download, func(string) error { return nil }, emit, func(s DownloadState) { done <- s }) + t.Cleanup(func() { d.Shutdown(time.Second) }) + + if err := d.Start("tiny"); err != nil { + t.Fatal(err) + } + if err := d.Cancel("tiny"); err != nil { + t.Fatal(err) + } + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("download did not finish") + } + mu.Lock() + defer mu.Unlock() + last := emitted[len(emitted)-1] + if last != DownloadCompleted || last != d.State().Status { + t.Fatalf("last emitted status %s (all %v), state %s", last, emitted, d.State().Status) + } +} diff --git a/internal/models/manager.go b/internal/models/manager.go index f6cd1b7..cc78c86 100644 --- a/internal/models/manager.go +++ b/internal/models/manager.go @@ -1,55 +1,47 @@ package models import ( + "context" + "errors" "fmt" "io" "net/http" "os" "path/filepath" + "strings" "sync" + "time" ) -const ( - // BaseURL is the base URL for downloading models - BaseURL = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main" +var ( + ErrUnknownModel = errors.New("unknown model") + ErrSizeMismatch = errors.New("file size does not match the expected model size") ) -// ModelDownloadInfo contains download information for a model -type ModelDownloadInfo struct { - Name string `json:"name"` - Filename string `json:"filename"` - URL string `json:"url"` - Size int64 `json:"size"` // bytes -} - -// AvailableModels returns download info for all available models -func AvailableModels() []ModelDownloadInfo { - return []ModelDownloadInfo{ - {"tiny", "ggml-tiny.bin", BaseURL + "/ggml-tiny.bin", 77691713}, - {"tiny.en", "ggml-tiny.en.bin", BaseURL + "/ggml-tiny.en.bin", 77704715}, - {"base", "ggml-base.bin", BaseURL + "/ggml-base.bin", 147951465}, - {"base.en", "ggml-base.en.bin", BaseURL + "/ggml-base.en.bin", 147964211}, - {"small", "ggml-small.bin", BaseURL + "/ggml-small.bin", 487601967}, - {"small.en", "ggml-small.en.bin", BaseURL + "/ggml-small.en.bin", 487614499}, - {"medium", "ggml-medium.bin", BaseURL + "/ggml-medium.bin", 1533763389}, - {"medium.en", "ggml-medium.en.bin", BaseURL + "/ggml-medium.en.bin", 1533774781}, - {"large-v3", "ggml-large-v3.bin", BaseURL + "/ggml-large-v3.bin", 3095033483}, - } -} - // Manager handles model downloads and management type Manager struct { modelsDir string + baseURL string + client *http.Client downloadLocks sync.Map // Model name -> *sync.Mutex; different models download independently. } // NewManager creates a new model manager func NewManager(modelsDir string) (*Manager, error) { - // Ensure models directory exists if err := os.MkdirAll(modelsDir, 0755); err != nil { return nil, fmt.Errorf("failed to create models directory: %w", err) } - return &Manager{modelsDir: modelsDir}, nil + m := &Manager{modelsDir: modelsDir, baseURL: defaultBaseURL()} + // Partials left by a crash, kill, or shutdown timeout are never resumed. + for _, spec := range catalog { + m.removePartials(spec, stalePartialAge) + } + return m, nil +} + +// SetBaseURL changes where model files are downloaded from. +func (m *Manager) SetBaseURL(baseURL string) { + m.baseURL = baseURL } // GetModelsDir returns the models directory path @@ -57,143 +49,205 @@ func (m *Manager) GetModelsDir() string { return m.modelsDir } -// IsModelDownloaded checks if a model is already downloaded +// ModelURL returns the download URL for a catalog model. +func (m *Manager) ModelURL(spec ModelSpec) string { + return m.baseURL + "/" + spec.Filename +} + +// IsModelDownloaded reports whether a catalog model exists on disk with its +// exact expected size. Absent, truncated, or oversized files are unavailable. func (m *Manager) IsModelDownloaded(modelName string) bool { - filename := fmt.Sprintf("ggml-%s.bin", modelName) - path := filepath.Join(m.modelsDir, filename) - _, err := os.Stat(path) - return err == nil + spec, ok := LookupModel(modelName) + if !ok { + return false + } + info, err := os.Stat(filepath.Join(m.modelsDir, spec.Filename)) + return err == nil && info.Mode().IsRegular() && info.Size() == spec.SizeBytes } // GetModelPath returns the full path to a model file func (m *Manager) GetModelPath(modelName string) string { - filename := fmt.Sprintf("ggml-%s.bin", modelName) - return filepath.Join(m.modelsDir, filename) + if spec, ok := LookupModel(modelName); ok { + return filepath.Join(m.modelsDir, spec.Filename) + } + return filepath.Join(m.modelsDir, fmt.Sprintf("ggml-%s.bin", modelName)) } // DownloadProgress is called during download with progress info type DownloadProgress func(downloaded, total int64) -// DownloadModel downloads a model from HuggingFace -func (m *Manager) DownloadModel(modelName string, progress DownloadProgress) error { - // Find model info - var modelInfo *ModelDownloadInfo - for _, info := range AvailableModels() { - if info.Name == modelName { - modelInfo = &info - break - } +func (m *Manager) httpClient() *http.Client { + if m.client != nil { + return m.client } - if modelInfo == nil { - return fmt.Errorf("unknown model: %s", modelName) + return http.DefaultClient +} + +// DownloadModel downloads a catalog model. The file is streamed to a unique +// temporary path, size-verified, closed, and atomically renamed into place. +// The temporary file is removed on failure or cancellation. Concurrent calls +// for the same model share one transfer; different models are independent. +func (m *Manager) DownloadModel(ctx context.Context, modelName string, progress DownloadProgress) (err error) { + spec, err := RequireModel(modelName) + if err != nil { + return err } - // Repeated UI requests must not write or publish the same model at once. // Waiters recheck the final file after the active download completes. lock, _ := m.downloadLocks.LoadOrStore(modelName, &sync.Mutex{}) mu := lock.(*sync.Mutex) mu.Lock() defer mu.Unlock() - // Create target path - targetPath := filepath.Join(m.modelsDir, modelInfo.Filename) + // The lock rules out another transfer in this process. The age check + // spares a live download by another Yap sharing this directory. + m.removePartials(spec, stalePartialAge) - // Check if already exists - if _, err := os.Stat(targetPath); err == nil { - return nil // Already downloaded + if m.IsModelDownloaded(modelName) { + if progress != nil { + progress(spec.SizeBytes, spec.SizeBytes) + } + return nil } - // Create temp file - out, err := os.CreateTemp(m.modelsDir, modelInfo.Filename+"-*.tmp") + targetPath := filepath.Join(m.modelsDir, spec.Filename) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, m.ModelURL(spec), nil) if err != nil { - return fmt.Errorf("failed to create file: %w", err) + return fmt.Errorf("failed to create request: %w", err) } - tmpPath := out.Name() - defer func() { - // Windows will not rename or remove a file while our write handle is - // open. Close before cleanup on every failure path as well. - _ = out.Close() - _ = os.Remove(tmpPath) - }() - - // Download - resp, err := http.Get(modelInfo.URL) + resp, err := m.httpClient().Do(req) if err != nil { - return fmt.Errorf("failed to download: %w", err) + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + return fmt.Errorf("could not reach the download server: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - return fmt.Errorf("download failed: HTTP %d", resp.StatusCode) + return fmt.Errorf("server returned HTTP %d", resp.StatusCode) + } + if resp.ContentLength >= 0 && resp.ContentLength != spec.SizeBytes { + return fmt.Errorf("%w (server reported %d bytes, expected %d)", ErrSizeMismatch, resp.ContentLength, spec.SizeBytes) } - // Get total size - total := resp.ContentLength - if total < 0 { - total = modelInfo.Size + out, err := os.CreateTemp(m.modelsDir, spec.Filename+"-*.tmp") + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + tmpPath := out.Name() + closed := false + defer func() { + if !closed { + out.Close() + } + if err != nil { + os.Remove(tmpPath) + } + }() + + total := spec.SizeBytes + if progress != nil { + progress(0, total) } - // Copy with progress var downloaded int64 - buf := make([]byte, 32*1024) // 32KB buffer + buf := make([]byte, 64*1024) for { - n, err := resp.Body.Read(buf) + n, readErr := resp.Body.Read(buf) if n > 0 { if _, writeErr := out.Write(buf[:n]); writeErr != nil { return fmt.Errorf("failed to write: %w", writeErr) } downloaded += int64(n) + if downloaded > total { + return fmt.Errorf("%w (received more than %d bytes)", ErrSizeMismatch, total) + } if progress != nil { progress(downloaded, total) } } - if err == io.EOF { + if readErr == io.EOF { break } - if err != nil { - return fmt.Errorf("download error: %w", err) + if readErr != nil { + if ctxErr := ctx.Err(); ctxErr != nil { + return ctxErr + } + return fmt.Errorf("connection interrupted: %w", readErr) } } - // Finish writing before publishing the model. A deferred Close alone - // runs too late: os.Rename fails with a sharing violation on Windows. + if downloaded != total { + return fmt.Errorf("%w (received %d bytes, expected %d)", ErrSizeMismatch, downloaded, total) + } + if err := out.Sync(); err != nil { + return fmt.Errorf("failed to flush download: %w", err) + } + // Close before publishing: Windows cannot rename a file with an open handle. + closed = true if err := out.Close(); err != nil { - return fmt.Errorf("failed to close downloaded model: %w", err) + return fmt.Errorf("failed to close download: %w", err) + } + if err := ctx.Err(); err != nil { + return err } if err := os.Rename(tmpPath, targetPath); err != nil { return fmt.Errorf("failed to finalize download: %w", err) } - return nil } -// DeleteModel removes a downloaded model +// DeleteModel removes a downloaded catalog model and any partial download. func (m *Manager) DeleteModel(modelName string) error { - path := m.GetModelPath(modelName) - if _, err := os.Stat(path); os.IsNotExist(err) { - return nil // Already deleted + spec, err := RequireModel(modelName) + if err != nil { + return err } - return os.Remove(path) + path := filepath.Join(m.modelsDir, spec.Filename) + m.removePartials(spec, 0) + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return err + } + return nil } -// ListDownloadedModels returns a list of downloaded model names -func (m *Manager) ListDownloadedModels() ([]string, error) { +// ListDownloadedModels returns the catalog models that are fully downloaded. +func (m *Manager) ListDownloadedModels() []string { + var names []string + for _, spec := range catalog { + if m.IsModelDownloaded(spec.Name) { + names = append(names, spec.Name) + } + } + return names +} + +// stalePartialAge is how long a partial must go unmodified before it is +// treated as abandoned. Active downloads write to their file continuously. +const stalePartialAge = 2 * time.Minute + +// removePartials deletes temporary files for spec, including the fixed-name +// partials written by earlier versions, that were last modified at least +// olderThan ago. +func (m *Manager) removePartials(spec ModelSpec, olderThan time.Duration) { entries, err := os.ReadDir(m.modelsDir) if err != nil { - return nil, err + return } - - var models []string + prefix := spec.Filename + "-" for _, entry := range entries { - if entry.IsDir() { + name := entry.Name() + if name != spec.Filename+".tmp" && !(strings.HasPrefix(name, prefix) && strings.HasSuffix(name, ".tmp")) { continue } - name := entry.Name() - if len(name) > 9 && name[:5] == "ggml-" && name[len(name)-4:] == ".bin" { - // Extract model name - modelName := name[5 : len(name)-4] - models = append(models, modelName) + if olderThan > 0 { + info, err := entry.Info() + if err != nil || time.Since(info.ModTime()) < olderThan { + continue + } } + _ = os.Remove(filepath.Join(m.modelsDir, name)) } - return models, nil } diff --git a/internal/models/manager_test.go b/internal/models/manager_test.go index 80a92cb..f5f7259 100644 --- a/internal/models/manager_test.go +++ b/internal/models/manager_test.go @@ -1,48 +1,348 @@ package models import ( - "io" + "bytes" + "context" + "errors" "net/http" + "net/http/httptest" "os" - "strings" + "path/filepath" + "strconv" "sync" "sync/atomic" "testing" "time" ) -type downloadTransport func(*http.Request) (*http.Response, error) +// testModel swaps the tiny.en catalog size for a small payload. +func withSmallCatalogEntry(t *testing.T, name string, size int64) ModelSpec { + t.Helper() + original := make([]ModelSpec, len(catalog)) + copy(original, catalog) + t.Cleanup(func() { catalog = original }) + for i := range catalog { + if catalog[i].Name == name { + catalog[i].SizeBytes = size + return catalog[i] + } + } + t.Fatalf("model %s not in catalog", name) + return ModelSpec{} +} + +func newTestManager(t *testing.T, baseURL string) *Manager { + t.Helper() + m, err := NewManager(t.TempDir()) + if err != nil { + t.Fatal(err) + } + m.SetBaseURL(baseURL) + return m +} + +func payload(n int) []byte { + return bytes.Repeat([]byte("y"), n) +} + +func serveBytes(t *testing.T, filename string, body []byte) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/"+filename { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Length", strconv.Itoa(len(body))) + w.Write(body) + })) + t.Cleanup(srv.Close) + return srv +} + +func assertNoTemp(t *testing.T, m *Manager, spec ModelSpec) { + t.Helper() + matches, err := filepath.Glob(filepath.Join(m.GetModelsDir(), spec.Filename+"*.tmp")) + if err != nil { + t.Fatal(err) + } + if len(matches) > 0 { + t.Fatalf("temporary files left behind: %v", matches) + } +} -func (f downloadTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } +func TestCatalogIsComplete(t *testing.T) { + seen := map[string]bool{} + for _, spec := range Catalog() { + if seen[spec.Name] { + t.Fatalf("duplicate model %s", spec.Name) + } + seen[spec.Name] = true + if spec.DisplayName == "" || spec.Filename == "" || spec.Size == "" || spec.UseCase == "" || spec.Provider != ProviderLocal { + t.Fatalf("incomplete metadata for %s: %+v", spec.Name, spec) + } + if spec.SizeBytes <= 0 || spec.Speed < 1 || spec.Speed > 5 || spec.Accuracy < 1 || spec.Accuracy > 5 { + t.Fatalf("invalid ratings or size for %s: %+v", spec.Name, spec) + } + if spec.Filename != "ggml-"+spec.Name+".bin" { + t.Fatalf("unexpected filename for %s: %s", spec.Name, spec.Filename) + } + } + if len(seen) != 9 { + t.Fatalf("expected 9 models, got %d", len(seen)) + } +} -type interruptedDownload struct{} +func TestDownloadSuccessReportsProgressAndFinalizes(t *testing.T) { + body := payload(200 * 1024) + spec := withSmallCatalogEntry(t, "tiny.en", int64(len(body))) + m := newTestManager(t, serveBytes(t, spec.Filename, body).URL) -func (interruptedDownload) Read(p []byte) (int, error) { - return copy(p, "partial model"), io.ErrUnexpectedEOF + var calls []int64 + err := m.DownloadModel(context.Background(), "tiny.en", func(downloaded, total int64) { + if total != spec.SizeBytes { + t.Errorf("total = %d, want %d", total, spec.SizeBytes) + } + calls = append(calls, downloaded) + }) + if err != nil { + t.Fatalf("download failed: %v", err) + } + if len(calls) < 2 || calls[0] != 0 || calls[len(calls)-1] != spec.SizeBytes { + t.Fatalf("unexpected progress calls: %v", calls) + } + if !m.IsModelDownloaded("tiny.en") { + t.Fatal("model should be downloaded") + } + got, err := os.ReadFile(m.GetModelPath("tiny.en")) + if err != nil || !bytes.Equal(got, body) { + t.Fatalf("final file mismatch (err=%v)", err) + } + assertNoTemp(t, m, spec) +} + +func TestDownloadCancellationRemovesTempFile(t *testing.T) { + spec := withSmallCatalogEntry(t, "tiny.en", 1<<20) + started := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", strconv.Itoa(1<<20)) + w.Write(payload(1024)) + w.(http.Flusher).Flush() + close(started) + <-r.Context().Done() + })) + t.Cleanup(srv.Close) + m := newTestManager(t, srv.URL) + + ctx, cancel := context.WithCancel(context.Background()) + errCh := make(chan error, 1) + go func() { errCh <- m.DownloadModel(ctx, "tiny.en", nil) }() + <-started + cancel() + + select { + case err := <-errCh: + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("download did not stop after cancellation") + } + if m.IsModelDownloaded("tiny.en") { + t.Fatal("cancelled model must not be downloaded") + } + assertNoTemp(t, m, spec) +} + +func TestDownloadHTTPFailure(t *testing.T) { + spec := withSmallCatalogEntry(t, "tiny.en", 10) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "nope", http.StatusInternalServerError) + })) + t.Cleanup(srv.Close) + m := newTestManager(t, srv.URL) + + err := m.DownloadModel(context.Background(), "tiny.en", nil) + if err == nil || err.Error() != "server returned HTTP 500" { + t.Fatalf("unexpected error: %v", err) + } + if m.IsModelDownloaded("tiny.en") { + t.Fatal("failed model must not be downloaded") + } + assertNoTemp(t, m, spec) +} + +func TestDownloadStreamFailure(t *testing.T) { + spec := withSmallCatalogEntry(t, "tiny.en", 4096) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", "4096") + w.Write(payload(1000)) + w.(http.Flusher).Flush() + conn, _, err := w.(http.Hijacker).Hijack() + if err == nil { + conn.Close() + } + })) + t.Cleanup(srv.Close) + m := newTestManager(t, srv.URL) + + if err := m.DownloadModel(context.Background(), "tiny.en", nil); err == nil { + t.Fatal("expected stream failure") + } + if m.IsModelDownloaded("tiny.en") { + t.Fatal("truncated model must not be downloaded") + } + assertNoTemp(t, m, spec) +} + +func TestDownloadRejectsIncorrectContentSize(t *testing.T) { + spec := withSmallCatalogEntry(t, "tiny.en", 2048) + m := newTestManager(t, serveBytes(t, spec.Filename, payload(1024)).URL) + + err := m.DownloadModel(context.Background(), "tiny.en", nil) + if !errors.Is(err, ErrSizeMismatch) { + t.Fatalf("expected size mismatch, got %v", err) + } + assertNoTemp(t, m, spec) +} + +func TestDownloadRejectsUnreportedWrongSize(t *testing.T) { + spec := withSmallCatalogEntry(t, "tiny.en", 2048) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Chunked response: no Content-Length, body shorter than expected. + w.Write(payload(512)) + w.(http.Flusher).Flush() + w.Write(payload(512)) + })) + t.Cleanup(srv.Close) + m := newTestManager(t, srv.URL) + + err := m.DownloadModel(context.Background(), "tiny.en", nil) + if !errors.Is(err, ErrSizeMismatch) { + t.Fatalf("expected size mismatch, got %v", err) + } + if m.IsModelDownloaded("tiny.en") { + t.Fatal("short model must not be downloaded") + } + assertNoTemp(t, m, spec) } -func TestConcurrentModelDownloadsShareOneTransfer(t *testing.T) { - previous := http.DefaultClient - t.Cleanup(func() { http.DefaultClient = previous }) +func TestExistingFilesAreValidatedBySize(t *testing.T) { + spec := withSmallCatalogEntry(t, "tiny.en", 100) + requests := 0 + var mu sync.Mutex + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + requests++ + mu.Unlock() + w.Header().Set("Content-Length", "100") + w.Write(payload(100)) + })) + t.Cleanup(srv.Close) + m := newTestManager(t, srv.URL) + path := m.GetModelPath("tiny.en") + + if err := os.WriteFile(path, payload(10), 0644); err != nil { + t.Fatal(err) + } + if m.IsModelDownloaded("tiny.en") { + t.Fatal("truncated existing file must not count as downloaded") + } + + if err := m.DownloadModel(context.Background(), "tiny.en", nil); err != nil { + t.Fatalf("re-download failed: %v", err) + } + if !m.IsModelDownloaded("tiny.en") || requests != 1 { + t.Fatalf("expected one re-download, requests=%d", requests) + } + + if err := m.DownloadModel(context.Background(), "tiny.en", nil); err != nil { + t.Fatalf("valid existing file should succeed: %v", err) + } + if requests != 1 { + t.Fatalf("valid existing file should not be re-downloaded, requests=%d", requests) + } + assertNoTemp(t, m, spec) +} + +func TestUnknownModelsAreRejected(t *testing.T) { + m := newTestManager(t, "http://127.0.0.1:1") + if err := m.DownloadModel(context.Background(), "../../etc/passwd", nil); !errors.Is(err, ErrUnknownModel) { + t.Fatalf("expected unknown model, got %v", err) + } + if err := m.DeleteModel("nope"); !errors.Is(err, ErrUnknownModel) { + t.Fatalf("expected unknown model, got %v", err) + } + if m.IsModelDownloaded("nope") { + t.Fatal("unknown model cannot be downloaded") + } +} + +func TestDeleteModelRemovesFileAndPartial(t *testing.T) { + spec := withSmallCatalogEntry(t, "base", 4) + m := newTestManager(t, "http://127.0.0.1:1") + path := m.GetModelPath("base") + os.WriteFile(path, payload(4), 0644) + os.WriteFile(path+".tmp", payload(2), 0644) + os.WriteFile(path+"-123.tmp", payload(2), 0644) + + if got := m.ListDownloadedModels(); len(got) != 1 || got[0] != "base" { + t.Fatalf("ListDownloadedModels = %v", got) + } + if err := m.DeleteModel("base"); err != nil { + t.Fatal(err) + } + if m.IsModelDownloaded("base") { + t.Fatal("model should be deleted") + } + assertNoTemp(t, m, spec) + if err := m.DeleteModel("base"); err != nil { + t.Fatalf("deleting a missing model should be a no-op: %v", err) + } +} + +func TestFinalizedFileIsClosedAndReplaceable(t *testing.T) { + body := payload(4096) + spec := withSmallCatalogEntry(t, "tiny.en", int64(len(body))) + m := newTestManager(t, serveBytes(t, spec.Filename, body).URL) + + if err := m.DownloadModel(context.Background(), "tiny.en", nil); err != nil { + t.Fatal(err) + } + // The finalized file must be fully written and removable, which fails on + // Windows when the handle is left open. + if err := m.DeleteModel("tiny.en"); err != nil { + t.Fatalf("finalized file could not be removed: %v", err) + } + if err := m.DownloadModel(context.Background(), "tiny.en", nil); err != nil { + t.Fatalf("second download failed: %v", err) + } + if !m.IsModelDownloaded("tiny.en") { + t.Fatal("second download should be finalized") + } +} + +func TestConcurrentSameModelDownloadsShareOneTransfer(t *testing.T) { + body := payload(64) + spec := withSmallCatalogEntry(t, "tiny", int64(len(body))) entered, release := make(chan struct{}), make(chan struct{}) var once sync.Once var requests atomic.Int32 - http.DefaultClient = &http.Client{Transport: downloadTransport(func(*http.Request) (*http.Response, error) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { requests.Add(1) once.Do(func() { close(entered) }) <-release - return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("complete model fixture")), ContentLength: 22, Header: make(http.Header)}, nil - })} - manager, err := NewManager(t.TempDir()) - if err != nil { - t.Fatal(err) - } + w.Header().Set("Content-Length", strconv.Itoa(len(body))) + w.Write(body) + })) + t.Cleanup(srv.Close) + m := newTestManager(t, srv.URL) + results := make(chan error, 8) for i := 0; i < cap(results); i++ { - go func() { results <- manager.DownloadModel("tiny", nil) }() + go func() { results <- m.DownloadModel(context.Background(), "tiny", nil) }() } <-entered - // Keep the first transfer open while repeated UI requests arrive. + // Keep the first transfer open while repeated requests arrive. time.Sleep(100 * time.Millisecond) close(release) for i := 0; i < cap(results); i++ { @@ -53,94 +353,41 @@ func TestConcurrentModelDownloadsShareOneTransfer(t *testing.T) { if got := requests.Load(); got != 1 { t.Errorf("HTTP transfers = %d, want 1", got) } - data, err := os.ReadFile(manager.GetModelPath("tiny")) - if err != nil || string(data) != "complete model fixture" { - t.Fatalf("model = %q, error = %v", data, err) - } -} - -func TestDownloadModelFinalization(t *testing.T) { - for _, tc := range []struct { - name string - status int - body io.Reader - wantError bool - }{ - {"success", http.StatusOK, strings.NewReader("complete model fixture"), false}, - {"http failure", http.StatusServiceUnavailable, strings.NewReader("unavailable"), true}, - {"interrupted stream", http.StatusOK, interruptedDownload{}, true}, - } { - t.Run(tc.name, func(t *testing.T) { - // Only replace HTTP; exercise the real download/write/rename path. - previous := http.DefaultClient - t.Cleanup(func() { http.DefaultClient = previous }) - http.DefaultClient = &http.Client{Transport: downloadTransport(func(r *http.Request) (*http.Response, error) { - if !strings.HasSuffix(r.URL.Path, "/ggml-tiny.en.bin") { - t.Fatalf("unexpected URL: %s", r.URL) - } - return &http.Response{StatusCode: tc.status, Body: io.NopCloser(tc.body), ContentLength: -1, Header: make(http.Header)}, nil - })} - dir := t.TempDir() - manager, err := NewManager(dir) - if err != nil { - t.Fatal(err) - } - err = manager.DownloadModel("tiny.en", nil) - if (err != nil) != tc.wantError { - t.Fatalf("DownloadModel error = %v", err) - } - entries, err := os.ReadDir(dir) - if err != nil { - t.Fatal(err) - } - if tc.wantError { - if len(entries) != 0 { - t.Fatalf("failed download left files: %v", entries) - } - return - } - if len(entries) != 1 || entries[0].Name() != "ggml-tiny.en.bin" { - t.Fatalf("unexpected files: %v", entries) - } - data, err := os.ReadFile(manager.GetModelPath("tiny.en")) - if err != nil || string(data) != "complete model fixture" { - t.Fatalf("model = %q, error = %v", data, err) - } - if !manager.IsModelDownloaded("tiny.en") { - t.Fatal("completed model not available") - } - }) + if !m.IsModelDownloaded("tiny") { + t.Fatal("model should be downloaded") } + assertNoTemp(t, m, spec) } func TestDifferentModelsDownloadIndependentlyAndFailureCanRetry(t *testing.T) { - previous := http.DefaultClient - t.Cleanup(func() { http.DefaultClient = previous }) + body := payload(16) + withSmallCatalogEntry(t, "tiny", int64(len(body))) + withSmallCatalogEntry(t, "tiny.en", int64(len(body))) entered := make(chan string, 2) release := make(chan struct{}) var requests atomic.Int32 - http.DefaultClient = &http.Client{Transport: downloadTransport(func(r *http.Request) (*http.Response, error) { - attempt := requests.Add(1) - if attempt <= 2 { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if requests.Add(1) <= 2 { entered <- r.URL.Path <-release - return &http.Response{StatusCode: http.StatusServiceUnavailable, Body: io.NopCloser(strings.NewReader("unavailable")), Header: make(http.Header)}, nil + http.Error(w, "unavailable", http.StatusServiceUnavailable) + return } - return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("model")), ContentLength: 5, Header: make(http.Header)}, nil - })} - manager, err := NewManager(t.TempDir()) - if err != nil { - t.Fatal(err) - } + w.Header().Set("Content-Length", strconv.Itoa(len(body))) + w.Write(body) + })) + t.Cleanup(srv.Close) + m := newTestManager(t, srv.URL) + results := make(chan error, 2) for _, name := range []string{"tiny", "tiny.en"} { - go func(name string) { results <- manager.DownloadModel(name, nil) }(name) + go func(name string) { results <- m.DownloadModel(context.Background(), name, nil) }(name) } for i := 0; i < 2; i++ { select { case <-entered: case <-time.After(5 * time.Second): - t.Error("different model downloads blocked each other") + t.Fatal("different model downloads blocked each other") } } close(release) @@ -150,8 +397,57 @@ func TestDifferentModelsDownloadIndependentlyAndFailureCanRetry(t *testing.T) { } } for _, name := range []string{"tiny", "tiny.en"} { - if err := manager.DownloadModel(name, nil); err != nil { + if err := m.DownloadModel(context.Background(), name, nil); err != nil { t.Fatalf("retry %s: %v", name, err) } } } + +func TestStalePartialsAreRemoved(t *testing.T) { + body := payload(32) + spec := withSmallCatalogEntry(t, "tiny", int64(len(body))) + dir := t.TempDir() + stale := []string{spec.Filename + "-123.tmp", spec.Filename + ".tmp", "ggml-base.en.bin-9.tmp"} + old := time.Now().Add(-time.Hour) + writeStale := func(name string) { + path := filepath.Join(dir, name) + if err := os.WriteFile(path, payload(8), 0644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(path, old, old); err != nil { + t.Fatal(err) + } + } + for _, name := range stale { + writeStale(name) + } + unrelated := filepath.Join(dir, "notes.tmp") + os.WriteFile(unrelated, payload(1), 0644) + // A fresh partial may belong to a live download in another Yap instance. + live := filepath.Join(dir, "ggml-small.bin-77.tmp") + os.WriteFile(live, payload(1), 0644) + + m, err := NewManager(dir) + if err != nil { + t.Fatal(err) + } + for _, name := range stale { + if _, err := os.Stat(filepath.Join(dir, name)); !os.IsNotExist(err) { + t.Fatalf("%s survived startup sweep", name) + } + } + if _, err := os.Stat(unrelated); err != nil { + t.Fatal("unrelated files must be kept") + } + if _, err := os.Stat(live); err != nil { + t.Fatal("recently written partials must be kept") + } + + // A stale partial appearing after startup is removed before the next transfer. + writeStale(spec.Filename + "-456.tmp") + m.SetBaseURL(serveBytes(t, spec.Filename, body).URL) + if err := m.DownloadModel(context.Background(), "tiny", nil); err != nil { + t.Fatal(err) + } + assertNoTemp(t, m, spec) +} diff --git a/internal/paths/paths.go b/internal/paths/paths.go new file mode 100644 index 0000000..9ac7ae6 --- /dev/null +++ b/internal/paths/paths.go @@ -0,0 +1,23 @@ +// Package paths resolves Yap's on-disk locations. +package paths + +import ( + "os" + "path/filepath" +) + +// ConfigDirEnv overrides the Yap data directory. It lets development builds +// run beside an installed Yap without sharing configuration, models, or logs. +const ConfigDirEnv = "YAP_CONFIG_DIR" + +// AppDataDir returns the directory holding Yap's config, models, and logs. +func AppDataDir() (string, error) { + if dir := os.Getenv(ConfigDirEnv); dir != "" { + return filepath.Abs(dir) + } + configDir, err := os.UserConfigDir() + if err != nil { + return "", err + } + return filepath.Join(configDir, "yap"), nil +} diff --git a/internal/paths/paths_test.go b/internal/paths/paths_test.go new file mode 100644 index 0000000..93c44ae --- /dev/null +++ b/internal/paths/paths_test.go @@ -0,0 +1,29 @@ +package paths + +import ( + "path/filepath" + "testing" +) + +func TestAppDataDirHonoursOverride(t *testing.T) { + dir := t.TempDir() + t.Setenv(ConfigDirEnv, dir) + got, err := AppDataDir() + if err != nil { + t.Fatal(err) + } + if got != dir { + t.Fatalf("AppDataDir() = %q, want %q", got, dir) + } +} + +func TestAppDataDirDefaultsToUserConfig(t *testing.T) { + t.Setenv(ConfigDirEnv, "") + got, err := AppDataDir() + if err != nil { + t.Fatal(err) + } + if filepath.Base(got) != "yap" { + t.Fatalf("AppDataDir() = %q, want a yap directory", got) + } +} diff --git a/internal/transcribe/engine.go b/internal/transcribe/engine.go index f3b59c3..2288003 100644 --- a/internal/transcribe/engine.go +++ b/internal/transcribe/engine.go @@ -1,6 +1,11 @@ package transcribe -import "context" +import ( + "context" + "strings" + + "yap/internal/models" +) // Provider identifies which transcription backend to use type Provider string @@ -10,7 +15,8 @@ const ( ProviderOpenAI Provider = "openai" // OpenAI Whisper API ) -// Model identifies which Whisper model to use +// Model identifies which Whisper model to use. Metadata for each model lives +// in the canonical catalog in internal/models. type Model string const ( @@ -25,27 +31,31 @@ const ( ModelLargeV3 Model = "large-v3" ) -// ModelInfo contains metadata about a model -type ModelInfo struct { - Name Model `json:"name"` - DisplayName string `json:"displayName"` - Size string `json:"size"` - EnglishOnly bool `json:"englishOnly"` +// WhisperLanguage returns the language passed to whisper.cpp for m: "en" for +// English-only models and "auto" (detect per recording) otherwise. whisper.cpp +// itself defaults to "en", which makes multilingual models translate other +// languages into English instead of transcribing them. +func (m Model) WhisperLanguage() string { + if spec, ok := models.LookupModel(string(m)); ok { + if spec.EnglishOnly { + return "en" + } + return "auto" + } + if strings.HasSuffix(string(m), ".en") { + return "en" + } + return "auto" } -// AvailableModels returns all available models -func AvailableModels() []ModelInfo { - return []ModelInfo{ - {ModelTiny, "Tiny", "~75MB", false}, - {ModelTinyEn, "Tiny (English)", "~75MB", true}, - {ModelBase, "Base", "~150MB", false}, - {ModelBaseEn, "Base (English)", "~150MB", true}, - {ModelSmall, "Small", "~500MB", false}, - {ModelSmallEn, "Small (English)", "~500MB", true}, - {ModelMedium, "Medium", "~1.5GB", false}, - {ModelMediumEn, "Medium (English)", "~1.5GB", true}, - {ModelLargeV3, "Large V3", "~3GB", false}, +// KnownModels lists the typed identifiers of every catalog model. +func KnownModels() []Model { + specs := models.Catalog() + out := make([]Model, len(specs)) + for i, spec := range specs { + out[i] = Model(spec.Name) } + return out } // Engine defines the interface for transcription backends diff --git a/internal/transcribe/engine_test.go b/internal/transcribe/engine_test.go new file mode 100644 index 0000000..e562769 --- /dev/null +++ b/internal/transcribe/engine_test.go @@ -0,0 +1,32 @@ +package transcribe + +import ( + "sort" + "testing" +) + +func TestModelConstantsMatchCatalog(t *testing.T) { + constants := []Model{ + ModelTiny, ModelTinyEn, ModelBase, ModelBaseEn, ModelSmall, + ModelSmallEn, ModelMedium, ModelMediumEn, ModelLargeV3, + } + known := KnownModels() + got := make([]string, len(known)) + for i, m := range known { + got[i] = string(m) + } + want := make([]string, len(constants)) + for i, m := range constants { + want[i] = string(m) + } + sort.Strings(got) + sort.Strings(want) + if len(got) != len(want) { + t.Fatalf("catalog models %v, constants %v", got, want) + } + for i := range got { + if got[i] != want[i] { + t.Fatalf("catalog models %v, constants %v", got, want) + } + } +} diff --git a/internal/transcribe/language_test.go b/internal/transcribe/language_test.go new file mode 100644 index 0000000..08120e7 --- /dev/null +++ b/internal/transcribe/language_test.go @@ -0,0 +1,176 @@ +package transcribe + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "yap/internal/models" +) + +func TestWhisperLanguageFollowsCatalog(t *testing.T) { + for _, spec := range models.Catalog() { + want := "auto" + if spec.EnglishOnly { + want = "en" + } + if got := Model(spec.Name).WhisperLanguage(); got != want { + t.Errorf("%s: language = %q, want %q", spec.Name, got, want) + } + } + if got := Model("custom.en").WhisperLanguage(); got != "en" { + t.Errorf("unknown .en model: language = %q, want en", got) + } + if got := Model("custom").WhisperLanguage(); got != "auto" { + t.Errorf("unknown model: language = %q, want auto", got) + } +} + +func TestPersistentWorkerSendsModelLanguage(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test worker launcher uses a shell script") + } + tmpDir := t.TempDir() + modelsDir := filepath.Join(tmpDir, "models") + if err := os.MkdirAll(modelsDir, 0755); err != nil { + t.Fatal(err) + } + for _, name := range []string{"ggml-base.en.bin", "ggml-small.bin"} { + if err := os.WriteFile(filepath.Join(modelsDir, name), []byte("model"), 0644); err != nil { + t.Fatal(err) + } + } + t.Setenv("YAP_TEST_WORKER_START_LOG", filepath.Join(tmpDir, "starts.log")) + languageLog := filepath.Join(tmpDir, "languages.log") + t.Setenv("YAP_TEST_WORKER_LANGUAGE_LOG", languageLog) + launcher := filepath.Join(tmpDir, "whisper-server") + script := fmt.Sprintf("#!/bin/sh\nexec %q -test.run=TestWhisperWorkerHelper -- \"$@\"\n", os.Args[0]) + if err := os.WriteFile(launcher, []byte(script), 0755); err != nil { + t.Fatal(err) + } + + engine := NewLocalEngine(modelsDir) + engine.SetWhisperServerBinary(launcher) + t.Cleanup(func() { _ = engine.Close() }) + + for _, model := range []Model{ModelBaseEn, ModelSmall} { + if err := engine.SetModel(model); err != nil { + t.Fatal(err) + } + if _, err := engine.TranscribeWAV(context.Background(), []byte("wav")); err != nil { + t.Fatalf("%s: %v", model, err) + } + } + + data, err := os.ReadFile(languageLog) + if err != nil { + t.Fatal(err) + } + if got := strings.Fields(string(data)); strings.Join(got, ",") != "en,auto" { + t.Fatalf("languages sent = %v, want [en auto]", got) + } +} + +func TestCLIFallbackPassesModelLanguage(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test whisper-cli is a shell script") + } + tmpDir := t.TempDir() + modelsDir := filepath.Join(tmpDir, "models") + if err := os.MkdirAll(modelsDir, 0755); err != nil { + t.Fatal(err) + } + for _, name := range []string{"ggml-tiny.en.bin", "ggml-medium.bin"} { + if err := os.WriteFile(filepath.Join(modelsDir, name), []byte("model"), 0644); err != nil { + t.Fatal(err) + } + } + languageLog := filepath.Join(tmpDir, "languages.log") + whisperBin := filepath.Join(tmpDir, "whisper-cli") + script := `#!/bin/sh +lang="" +out="" +while [ "$#" -gt 0 ]; do + case "$1" in + -l) shift; lang="$1" ;; + -of) shift; out="$1" ;; + esac + shift +done +echo "$lang" >> "` + languageLog + `" +printf 'cli transcript\n' > "$out.txt" +` + if err := os.WriteFile(whisperBin, []byte(script), 0755); err != nil { + t.Fatal(err) + } + + engine := NewLocalEngine(modelsDir) + engine.SetWhisperBinary(whisperBin) + engine.SetWhisperServerBinary(filepath.Join(tmpDir, "missing-whisper-server")) + + for _, model := range []Model{ModelTinyEn, ModelMedium} { + if err := engine.SetModel(model); err != nil { + t.Fatal(err) + } + text, err := engine.TranscribeWAV(context.Background(), []byte("wav")) + if err != nil { + t.Fatalf("%s: %v", model, err) + } + if text != "cli transcript" { + t.Fatalf("%s: transcript = %q", model, text) + } + } + + data, err := os.ReadFile(languageLog) + if err != nil { + t.Fatal(err) + } + got := strings.Fields(string(data)) + if len(got) == 0 || got[0] != "en" || got[len(got)-1] != "auto" { + t.Fatalf("languages passed = %v, want tiny.en=en then medium=auto", got) + } + for _, lang := range got { + if lang != "en" && lang != "auto" { + t.Fatalf("unexpected language %q in %v", lang, got) + } + } +} + +// Runs on every OS (including Windows CI, where the shell-script worker tests +// are skipped): the inference request must carry the model's language. +func TestWorkerRequestCarriesLanguage(t *testing.T) { + var got []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/inference" { + http.NotFound(w, r) + return + } + if err := r.ParseMultipartForm(1 << 20); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + got = append(got, r.FormValue("language")) + _, _ = w.Write([]byte(" transcript \n")) + })) + t.Cleanup(srv.Close) + + worker := &whisperWorker{client: srv.Client(), baseURL: srv.URL} + for _, model := range []Model{ModelBaseEn, ModelLargeV3} { + text, err := worker.transcribe(context.Background(), []byte("wav"), model.WhisperLanguage()) + if err != nil { + t.Fatal(err) + } + if text != "transcript" { + t.Fatalf("transcript = %q", text) + } + } + if strings.Join(got, ",") != "en,auto" { + t.Fatalf("languages sent = %v, want [en auto]", got) + } +} diff --git a/internal/transcribe/whisper_local.go b/internal/transcribe/whisper_local.go index 75f4109..c3bfcc2 100644 --- a/internal/transcribe/whisper_local.go +++ b/internal/transcribe/whisper_local.go @@ -80,15 +80,14 @@ func (e *LocalEngine) TranscribeWAV(ctx context.Context, wavData []byte) (string return "", err } - // Get model path - modelPath := e.getModelPath() + modelPath, language := e.modelSnapshot() if _, err := os.Stat(modelPath); os.IsNotExist(err) { return "", fmt.Errorf("model file not found: %s. Please download the model first", modelPath) } var workerErr error if e.findWhisperServerBinary() != "" { - text, err := e.transcribePersistent(ctx, wavData, modelPath) + text, err := e.transcribePersistent(ctx, wavData, modelPath, language) if err == nil { return text, nil } @@ -124,6 +123,7 @@ func (e *LocalEngine) TranscribeWAV(ctx context.Context, wavData []byte) (string baseArgs := []string{ "-m", modelPath, "-f", tmpFile.Name(), + "-l", language, "--no-timestamps", "-otxt", "-of", tmpFile.Name(), @@ -598,6 +598,14 @@ func (e *LocalEngine) Name() Provider { return ProviderLocal } +// modelSnapshot returns the current model's file path and whisper.cpp +// language together, so a concurrent SetModel cannot mix the two. +func (e *LocalEngine) modelSnapshot() (path string, language string) { + e.modelMu.RLock() + defer e.modelMu.RUnlock() + return filepath.Join(e.modelsDir, fmt.Sprintf("ggml-%s.bin", e.model)), e.model.WhisperLanguage() +} + // getModelPath returns the full path to the model file func (e *LocalEngine) getModelPath() string { e.modelMu.RLock() diff --git a/internal/transcribe/whisper_worker.go b/internal/transcribe/whisper_worker.go index ea8ad65..632a389 100644 --- a/internal/transcribe/whisper_worker.go +++ b/internal/transcribe/whisper_worker.go @@ -74,7 +74,7 @@ func (b *lockedBuffer) String() string { return trimCommandOutput(b.buf.String()) } -func (e *LocalEngine) transcribePersistent(ctx context.Context, wavData []byte, modelPath string) (string, error) { +func (e *LocalEngine) transcribePersistent(ctx context.Context, wavData []byte, modelPath string, language string) (string, error) { operationCtx, cancel, err := e.operationContext(ctx) if err != nil { return "", err @@ -92,7 +92,7 @@ func (e *LocalEngine) transcribePersistent(ctx context.Context, wavData []byte, } return "", err } - text, err := worker.transcribe(ctx, wavData) + text, err := worker.transcribe(ctx, wavData, language) if err == nil { e.lastBackend = workerBackendName(worker) return text, nil @@ -115,7 +115,7 @@ func (e *LocalEngine) transcribePersistent(ctx context.Context, wavData []byte, } return "", fmt.Errorf("Metal worker failed: %v; CPU worker failed to start: %w", err, startErr) } - text, err = worker.transcribe(ctx, wavData) + text, err = worker.transcribe(ctx, wavData, language) if err == nil { e.lastBackend = workerBackendName(worker) } else { @@ -337,7 +337,7 @@ func (w *whisperWorker) waitReady(ctx context.Context) error { } } -func (w *whisperWorker) transcribe(ctx context.Context, wavData []byte) (string, error) { +func (w *whisperWorker) transcribe(ctx context.Context, wavData []byte, language string) (string, error) { started := time.Now() var body bytes.Buffer form := multipart.NewWriter(&body) @@ -349,6 +349,7 @@ func (w *whisperWorker) transcribe(ctx context.Context, wavData []byte) (string, return "", err } _ = form.WriteField("response_format", "text") + _ = form.WriteField("language", language) _ = form.WriteField("no_timestamps", "true") if err := form.Close(); err != nil { return "", err diff --git a/internal/transcribe/whisper_worker_test.go b/internal/transcribe/whisper_worker_test.go index 0006983..d10c8e1 100644 --- a/internal/transcribe/whisper_worker_test.go +++ b/internal/transcribe/whisper_worker_test.go @@ -268,6 +268,12 @@ func TestWhisperWorkerHelper(t *testing.T) { http.Error(w, err.Error(), http.StatusBadRequest) return } + if languageLog := os.Getenv("YAP_TEST_WORKER_LANGUAGE_LOG"); languageLog != "" { + if f, err := os.OpenFile(languageLog, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644); err == nil { + _, _ = f.WriteString(r.FormValue("language") + "\n") + _ = f.Close() + } + } file, _, err := r.FormFile("file") if err != nil { http.Error(w, err.Error(), http.StatusBadRequest)