initial working version of multiple models + cleanup and onboarding
This commit is contained in:
parent
5b1872a12e
commit
9036c05088
21 changed files with 2026 additions and 211 deletions
1
.github/workflows/release.yml
vendored
1
.github/workflows/release.yml
vendored
|
|
@ -69,7 +69,6 @@ jobs:
|
|||
- name: Download model files
|
||||
run: |
|
||||
mkdir -p src-tauri/resources/models
|
||||
curl -o src-tauri/resources/models/ggml-small.bin https://blob.handy.computer/ggml-small.bin
|
||||
curl -o src-tauri/resources/models/silero_vad_v4.onnx https://blob.handy.computer/silero_vad_v4.onnx
|
||||
|
||||
- name: install frontend dependencies
|
||||
|
|
|
|||
19
README.md
19
README.md
|
|
@ -73,20 +73,23 @@ For development, you need to download the required model files:
|
|||
mkdir -p src-tauri/resources/models
|
||||
```
|
||||
|
||||
2. Download the required model files:
|
||||
2. Download the required VAD model for development:
|
||||
```bash
|
||||
# Download Whisper model (Small model)
|
||||
curl -o src-tauri/resources/models/ggml-small.bin https://blob.handy.computer/ggml-small.bin
|
||||
|
||||
# Download Silero VAD model
|
||||
# Download Silero VAD model (required for voice activity detection)
|
||||
curl -o src-tauri/resources/models/silero_vad_v4.onnx https://blob.handy.computer/silero_vad_v4.onnx
|
||||
```
|
||||
|
||||
**Alternative Whisper Models:**
|
||||
**Note:** Whisper models are no longer bundled with the app. Users will download their preferred model (Small, Medium, Turbo, or Large) from within the app on first run.
|
||||
|
||||
The `ggml-small.bin` file can be replaced with any GGML model for whisper.cpp. Available models can be found at [huggingface.co/ggerganov/whisper.cpp](https://huggingface.co/ggerganov/whisper.cpp/tree/main).
|
||||
**Whisper Models:**
|
||||
|
||||
**Important:** If you use a different Whisper model, you'll need to update the filename reference in the manager's transcription code to match your chosen model file.
|
||||
The app now supports dynamic model downloading and switching:
|
||||
- **Small**: Fast, good for most use cases
|
||||
- **Medium**: Better accuracy, balanced performance
|
||||
- **Turbo**: Optimized large model with improved speed
|
||||
- **Large**: Highest accuracy, slower processing
|
||||
|
||||
Users can download and switch between models directly from the app's settings interface. No models are bundled with the app, reducing the initial download size.
|
||||
|
||||
## Architecture
|
||||
|
||||
|
|
|
|||
331
src-tauri/Cargo.lock
generated
331
src-tauri/Cargo.lock
generated
|
|
@ -1995,6 +1995,25 @@ dependencies = [
|
|||
"syn 2.0.96",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.3.26"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"futures-util",
|
||||
"http 0.2.12",
|
||||
"indexmap 2.7.1",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "2.4.1"
|
||||
|
|
@ -2013,9 +2032,11 @@ dependencies = [
|
|||
"cpal",
|
||||
"enigo",
|
||||
"env_logger",
|
||||
"futures-util",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rdev",
|
||||
"reqwest 0.11.27",
|
||||
"rodio",
|
||||
"rubato",
|
||||
"serde",
|
||||
|
|
@ -2100,6 +2121,17 @@ dependencies = [
|
|||
"match_token",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"fnv",
|
||||
"itoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.2.0"
|
||||
|
|
@ -2111,6 +2143,17 @@ dependencies = [
|
|||
"itoa",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-body"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http 0.2.12",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-body"
|
||||
version = "1.0.1"
|
||||
|
|
@ -2118,7 +2161,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"http",
|
||||
"http 1.2.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2129,8 +2172,8 @@ checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f"
|
|||
dependencies = [
|
||||
"bytes",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http 1.2.0",
|
||||
"http-body 1.0.1",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
|
|
@ -2140,12 +2183,42 @@ version = "1.10.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2d708df4e7140240a16cd6ab0ab65c972d7433ab77819ea693fde9c43811e2a"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "humantime"
|
||||
version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "0.14.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http 0.2.12",
|
||||
"http-body 0.4.6",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"want",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.6.0"
|
||||
|
|
@ -2155,8 +2228,8 @@ dependencies = [
|
|||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http 1.2.0",
|
||||
"http-body 1.0.1",
|
||||
"httparse",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
|
|
@ -2172,8 +2245,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"http",
|
||||
"hyper",
|
||||
"http 1.2.0",
|
||||
"hyper 1.6.0",
|
||||
"hyper-util",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
|
|
@ -2183,6 +2256,19 @@ dependencies = [
|
|||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-tls"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"hyper 0.14.32",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.10"
|
||||
|
|
@ -2192,9 +2278,9 @@ dependencies = [
|
|||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"hyper",
|
||||
"http 1.2.0",
|
||||
"http-body 1.0.1",
|
||||
"hyper 1.6.0",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"tokio",
|
||||
|
|
@ -2918,6 +3004,23 @@ dependencies = [
|
|||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndarray"
|
||||
version = "0.16.1"
|
||||
|
|
@ -3429,6 +3532,50 @@ dependencies = [
|
|||
"pathdiff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.73"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8"
|
||||
dependencies = [
|
||||
"bitflags 2.8.0",
|
||||
"cfg-if",
|
||||
"foreign-types 0.3.2",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.96",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.109"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
|
|
@ -4143,6 +4290,48 @@ version = "0.8.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.11.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"bytes",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"http 0.2.12",
|
||||
"http-body 0.4.6",
|
||||
"hyper 0.14.32",
|
||||
"hyper-tls",
|
||||
"ipnet",
|
||||
"js-sys",
|
||||
"log",
|
||||
"mime",
|
||||
"native-tls",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls-pemfile 1.0.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper 0.1.2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-util",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"winreg 0.50.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.12"
|
||||
|
|
@ -4153,10 +4342,10 @@ dependencies = [
|
|||
"bytes",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http-body",
|
||||
"http 1.2.0",
|
||||
"http-body 1.0.1",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper 1.6.0",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"ipnet",
|
||||
|
|
@ -4168,12 +4357,12 @@ dependencies = [
|
|||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"rustls-pemfile 2.2.0",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"sync_wrapper 1.0.2",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
|
|
@ -4317,6 +4506,15 @@ dependencies = [
|
|||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pemfile"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pemfile"
|
||||
version = "2.2.0"
|
||||
|
|
@ -4376,6 +4574,15 @@ dependencies = [
|
|||
"libsamplerate-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schemars"
|
||||
version = "0.8.21"
|
||||
|
|
@ -4409,6 +4616,29 @@ version = "1.2.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
|
||||
dependencies = [
|
||||
"bitflags 2.8.0",
|
||||
"core-foundation 0.9.4",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.24.0"
|
||||
|
|
@ -4875,6 +5105,12 @@ dependencies = [
|
|||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sync_wrapper"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160"
|
||||
|
||||
[[package]]
|
||||
name = "sync_wrapper"
|
||||
version = "1.0.2"
|
||||
|
|
@ -4904,6 +5140,27 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration-sys"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "6.2.2"
|
||||
|
|
@ -4999,7 +5256,7 @@ dependencies = [
|
|||
"glob",
|
||||
"gtk",
|
||||
"heck 0.5.0",
|
||||
"http",
|
||||
"http 1.2.0",
|
||||
"image",
|
||||
"jni",
|
||||
"libc",
|
||||
|
|
@ -5013,7 +5270,7 @@ dependencies = [
|
|||
"percent-encoding",
|
||||
"plist",
|
||||
"raw-window-handle",
|
||||
"reqwest",
|
||||
"reqwest 0.12.12",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
|
|
@ -5263,13 +5520,13 @@ dependencies = [
|
|||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"http",
|
||||
"http 1.2.0",
|
||||
"infer",
|
||||
"log",
|
||||
"minisign-verify",
|
||||
"osakit",
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
"reqwest 0.12.12",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -5294,7 +5551,7 @@ dependencies = [
|
|||
"cookie",
|
||||
"dpi",
|
||||
"gtk",
|
||||
"http",
|
||||
"http 1.2.0",
|
||||
"jni",
|
||||
"objc2 0.6.1",
|
||||
"objc2-ui-kit",
|
||||
|
|
@ -5314,7 +5571,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "fe52ed0ef40fd7ad51a620ecb3018e32eba3040bb95025216a962a37f6f050c5"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"http",
|
||||
"http 1.2.0",
|
||||
"jni",
|
||||
"log",
|
||||
"objc2 0.6.1",
|
||||
|
|
@ -5347,7 +5604,7 @@ dependencies = [
|
|||
"dunce",
|
||||
"glob",
|
||||
"html5ever",
|
||||
"http",
|
||||
"http 1.2.0",
|
||||
"infer",
|
||||
"json-patch",
|
||||
"kuchikiki",
|
||||
|
|
@ -5542,6 +5799,16 @@ dependencies = [
|
|||
"syn 2.0.96",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-native-tls"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||
dependencies = [
|
||||
"native-tls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.1"
|
||||
|
|
@ -5630,7 +5897,7 @@ dependencies = [
|
|||
"futures-core",
|
||||
"futures-util",
|
||||
"pin-project-lite",
|
||||
"sync_wrapper",
|
||||
"sync_wrapper 1.0.2",
|
||||
"tokio",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
|
|
@ -5887,6 +6154,12 @@ dependencies = [
|
|||
"samplerate",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "version-compare"
|
||||
version = "0.2.0"
|
||||
|
|
@ -6791,6 +7064,16 @@ dependencies = [
|
|||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.50.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.55.0"
|
||||
|
|
@ -6837,7 +7120,7 @@ dependencies = [
|
|||
"gdkx11",
|
||||
"gtk",
|
||||
"html5ever",
|
||||
"http",
|
||||
"http 1.2.0",
|
||||
"javascriptcore-rs",
|
||||
"jni",
|
||||
"kuchikiki",
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ tauri-plugin-os = "2"
|
|||
enigo = "0.5.0"
|
||||
tauri-plugin-process = "2"
|
||||
rodio = "0.20.1"
|
||||
reqwest = { version = "0.11", features = ["json", "stream"] }
|
||||
futures-util = "0.3"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
whisper-rs = { version = "0.13.2", features = ["metal"] }
|
||||
|
|
|
|||
1
src-tauri/src/commands/mod.rs
Normal file
1
src-tauri/src/commands/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
|||
pub mod models;
|
||||
115
src-tauri/src/commands/models.rs
Normal file
115
src-tauri/src/commands/models.rs
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
use crate::managers::model::{ModelInfo, ModelManager};
|
||||
use crate::managers::transcription::TranscriptionManager;
|
||||
use crate::settings::{get_settings, write_settings};
|
||||
use std::sync::Arc;
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_available_models(
|
||||
model_manager: State<'_, Arc<ModelManager>>,
|
||||
) -> Result<Vec<ModelInfo>, String> {
|
||||
Ok(model_manager.get_available_models())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_model_info(
|
||||
model_manager: State<'_, Arc<ModelManager>>,
|
||||
model_id: String,
|
||||
) -> Result<Option<ModelInfo>, String> {
|
||||
Ok(model_manager.get_model_info(&model_id))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn download_model(
|
||||
model_manager: State<'_, Arc<ModelManager>>,
|
||||
model_id: String,
|
||||
) -> Result<(), String> {
|
||||
model_manager
|
||||
.download_model(&model_id)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_model(
|
||||
model_manager: State<'_, Arc<ModelManager>>,
|
||||
model_id: String,
|
||||
) -> Result<(), String> {
|
||||
model_manager
|
||||
.delete_model(&model_id)
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_active_model(
|
||||
app_handle: AppHandle,
|
||||
model_manager: State<'_, Arc<ModelManager>>,
|
||||
transcription_manager: State<'_, Arc<TranscriptionManager>>,
|
||||
model_id: String,
|
||||
) -> Result<(), String> {
|
||||
// Check if model exists and is available
|
||||
let model_info = model_manager
|
||||
.get_model_info(&model_id)
|
||||
.ok_or_else(|| format!("Model not found: {}", model_id))?;
|
||||
|
||||
if !model_info.is_downloaded {
|
||||
return Err(format!("Model not downloaded: {}", model_id));
|
||||
}
|
||||
|
||||
// Load the model in the transcription manager
|
||||
transcription_manager
|
||||
.load_model(&model_id)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// Update settings
|
||||
let mut settings = get_settings(&app_handle);
|
||||
settings.selected_model = model_id.clone();
|
||||
write_settings(&app_handle, settings);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_current_model(app_handle: AppHandle) -> Result<String, String> {
|
||||
let settings = get_settings(&app_handle);
|
||||
Ok(settings.selected_model)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_transcription_model_status(
|
||||
transcription_manager: State<'_, Arc<TranscriptionManager>>,
|
||||
) -> Result<Option<String>, String> {
|
||||
Ok(transcription_manager.get_current_model())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn is_model_loading(
|
||||
transcription_manager: State<'_, Arc<TranscriptionManager>>,
|
||||
) -> Result<bool, String> {
|
||||
// Check if transcription manager has a loaded model
|
||||
let current_model = transcription_manager.get_current_model();
|
||||
Ok(current_model.is_none())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn has_any_models_available(
|
||||
model_manager: State<'_, Arc<ModelManager>>,
|
||||
) -> Result<bool, String> {
|
||||
let models = model_manager.get_available_models();
|
||||
Ok(models.iter().any(|m| m.is_downloaded))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn has_any_models_or_downloads(
|
||||
model_manager: State<'_, Arc<ModelManager>>,
|
||||
) -> Result<bool, String> {
|
||||
let models = model_manager.get_available_models();
|
||||
// Return true if any models are downloaded OR if any downloads are in progress
|
||||
Ok(models.iter().any(|m| m.is_downloaded))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_recommended_first_model() -> Result<String, String> {
|
||||
// Recommend small model for first-time users
|
||||
Ok("small".to_string())
|
||||
}
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
mod actions;
|
||||
mod commands;
|
||||
mod managers;
|
||||
mod settings;
|
||||
mod shortcut;
|
||||
mod utils;
|
||||
|
||||
use managers::audio::AudioRecordingManager;
|
||||
use managers::model::ModelManager;
|
||||
use managers::transcription::TranscriptionManager;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
|
@ -135,13 +137,16 @@ pub fn run() {
|
|||
let recording_manager = Arc::new(
|
||||
AudioRecordingManager::new(app).expect("Failed to initialize recording manager"),
|
||||
);
|
||||
let model_manager =
|
||||
Arc::new(ModelManager::new(&app).expect("Failed to initialize model manager"));
|
||||
let transcription_manager = Arc::new(
|
||||
TranscriptionManager::new(&app)
|
||||
TranscriptionManager::new(&app, model_manager.clone())
|
||||
.expect("Failed to initialize transcription manager"),
|
||||
);
|
||||
|
||||
// Add managers to Tauri's managed state
|
||||
app.manage(recording_manager.clone());
|
||||
app.manage(model_manager.clone());
|
||||
app.manage(transcription_manager.clone());
|
||||
|
||||
shortcut::init_shortcuts(app);
|
||||
|
|
@ -169,7 +174,18 @@ pub fn run() {
|
|||
shortcut::reset_binding,
|
||||
shortcut::change_ptt_setting,
|
||||
shortcut::change_audio_feedback_setting,
|
||||
trigger_update_check
|
||||
trigger_update_check,
|
||||
commands::models::get_available_models,
|
||||
commands::models::get_model_info,
|
||||
commands::models::download_model,
|
||||
commands::models::delete_model,
|
||||
commands::models::set_active_model,
|
||||
commands::models::get_current_model,
|
||||
commands::models::get_transcription_model_status,
|
||||
commands::models::is_model_loading,
|
||||
commands::models::has_any_models_available,
|
||||
commands::models::has_any_models_or_downloads,
|
||||
commands::models::get_recommended_first_model
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
pub mod audio;
|
||||
pub mod model;
|
||||
pub mod transcription;
|
||||
|
|
|
|||
295
src-tauri/src/managers/model.rs
Normal file
295
src-tauri/src/managers/model.rs
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
use anyhow::Result;
|
||||
use futures_util::StreamExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use tauri::{App, AppHandle, Emitter, Manager};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModelInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub filename: String,
|
||||
pub url: Option<String>,
|
||||
pub size_mb: u64,
|
||||
pub is_downloaded: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DownloadProgress {
|
||||
pub model_id: String,
|
||||
pub downloaded: u64,
|
||||
pub total: u64,
|
||||
pub percentage: f64,
|
||||
}
|
||||
|
||||
pub struct ModelManager {
|
||||
app_handle: AppHandle,
|
||||
models_dir: PathBuf,
|
||||
available_models: Mutex<HashMap<String, ModelInfo>>,
|
||||
}
|
||||
|
||||
impl ModelManager {
|
||||
pub fn new(app: &App) -> Result<Self> {
|
||||
let app_handle = app.app_handle().clone();
|
||||
|
||||
// Create models directory in app data
|
||||
let models_dir = app
|
||||
.path()
|
||||
.app_data_dir()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to get app data dir: {}", e))?
|
||||
.join("models");
|
||||
|
||||
if !models_dir.exists() {
|
||||
fs::create_dir_all(&models_dir)?;
|
||||
}
|
||||
|
||||
let mut available_models = HashMap::new();
|
||||
|
||||
available_models.insert(
|
||||
"small".to_string(),
|
||||
ModelInfo {
|
||||
id: "small".to_string(),
|
||||
name: "Whisper Small".to_string(),
|
||||
description: "Fast and efficient, great for most use cases".to_string(),
|
||||
filename: "ggml-small.bin".to_string(),
|
||||
url: Some("https://blob.handy.computer/ggml-small.bin".to_string()),
|
||||
size_mb: 244,
|
||||
is_downloaded: false,
|
||||
},
|
||||
);
|
||||
|
||||
// Add downloadable models
|
||||
available_models.insert(
|
||||
"medium".to_string(),
|
||||
ModelInfo {
|
||||
id: "medium".to_string(),
|
||||
name: "Whisper Medium".to_string(),
|
||||
description: "Good accuracy, medium speed".to_string(),
|
||||
filename: "ggml-medium-q5_0.bin".to_string(),
|
||||
url: Some("https://blob.handy.computer/ggml-medium-q5_0.bin".to_string()),
|
||||
size_mb: 539, // Approximate size
|
||||
is_downloaded: false,
|
||||
},
|
||||
);
|
||||
|
||||
available_models.insert(
|
||||
"turbo".to_string(),
|
||||
ModelInfo {
|
||||
id: "turbo".to_string(),
|
||||
name: "Whisper Turbo".to_string(),
|
||||
description: "Good accuracy, medium speed".to_string(),
|
||||
filename: "ggml-large-v3-turbo-q5_0.bin".to_string(),
|
||||
url: Some("https://blob.handy.computer/ggml-large-v3-turbo-q5_0.bin".to_string()),
|
||||
size_mb: 574, // Approximate size
|
||||
is_downloaded: false,
|
||||
},
|
||||
);
|
||||
|
||||
available_models.insert(
|
||||
"large".to_string(),
|
||||
ModelInfo {
|
||||
id: "large".to_string(),
|
||||
name: "Whisper Large".to_string(),
|
||||
description: "Highest accuracy, but slow.".to_string(),
|
||||
filename: "ggml-large-v3-q5_0.bin".to_string(),
|
||||
url: Some("https://blob.handy.computer/ggml-large-v3-q5_0.bin".to_string()),
|
||||
size_mb: 1080, // Approximate size
|
||||
is_downloaded: false,
|
||||
},
|
||||
);
|
||||
|
||||
let manager = Self {
|
||||
app_handle,
|
||||
models_dir,
|
||||
available_models: Mutex::new(available_models),
|
||||
};
|
||||
|
||||
// Migrate any bundled models to user directory
|
||||
manager.migrate_bundled_models()?;
|
||||
|
||||
// Check which models are already downloaded
|
||||
manager.update_download_status()?;
|
||||
|
||||
Ok(manager)
|
||||
}
|
||||
|
||||
pub fn get_available_models(&self) -> Vec<ModelInfo> {
|
||||
let models = self.available_models.lock().unwrap();
|
||||
models.values().cloned().collect()
|
||||
}
|
||||
|
||||
pub fn get_model_info(&self, model_id: &str) -> Option<ModelInfo> {
|
||||
let models = self.available_models.lock().unwrap();
|
||||
models.get(model_id).cloned()
|
||||
}
|
||||
|
||||
fn migrate_bundled_models(&self) -> Result<()> {
|
||||
// Check for bundled models and copy them to user directory
|
||||
let bundled_models = ["ggml-small.bin"]; // Add other bundled models here if any
|
||||
|
||||
for filename in &bundled_models {
|
||||
let bundled_path = self.app_handle.path().resolve(
|
||||
&format!("resources/models/{}", filename),
|
||||
tauri::path::BaseDirectory::Resource,
|
||||
);
|
||||
|
||||
if let Ok(bundled_path) = bundled_path {
|
||||
if bundled_path.exists() {
|
||||
let user_path = self.models_dir.join(filename);
|
||||
|
||||
// Only copy if user doesn't already have the model
|
||||
if !user_path.exists() {
|
||||
println!("Migrating bundled model {} to user directory", filename);
|
||||
fs::copy(&bundled_path, &user_path)?;
|
||||
println!("Successfully migrated {}", filename);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_download_status(&self) -> Result<()> {
|
||||
let mut models = self.available_models.lock().unwrap();
|
||||
|
||||
for model in models.values_mut() {
|
||||
let model_path = self.models_dir.join(&model.filename);
|
||||
model.is_downloaded = model_path.exists();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn download_model(&self, model_id: &str) -> Result<()> {
|
||||
let model_info = {
|
||||
let models = self.available_models.lock().unwrap();
|
||||
models.get(model_id).cloned()
|
||||
};
|
||||
|
||||
let model_info =
|
||||
model_info.ok_or_else(|| anyhow::anyhow!("Model not found: {}", model_id))?;
|
||||
|
||||
let url = model_info
|
||||
.url
|
||||
.ok_or_else(|| anyhow::anyhow!("No download URL for model"))?;
|
||||
let model_path = self.models_dir.join(&model_info.filename);
|
||||
|
||||
// Don't download if downloaded version already exists
|
||||
if model_path.exists() {
|
||||
// Update status and return
|
||||
self.update_download_status()?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("Downloading model {} from {}", model_id, url);
|
||||
|
||||
// Create HTTP client
|
||||
let client = reqwest::Client::new();
|
||||
let response = client.get(&url).send().await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"Failed to download model: HTTP {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
let total_size = response.content_length().unwrap_or(0);
|
||||
let mut downloaded = 0u64;
|
||||
let mut stream = response.bytes_stream();
|
||||
|
||||
// Create the file
|
||||
let mut file = std::fs::File::create(&model_path)?;
|
||||
|
||||
// Download with progress
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk?;
|
||||
file.write_all(&chunk)?;
|
||||
downloaded += chunk.len() as u64;
|
||||
|
||||
let percentage = if total_size > 0 {
|
||||
(downloaded as f64 / total_size as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Emit progress event
|
||||
let progress = DownloadProgress {
|
||||
model_id: model_id.to_string(),
|
||||
downloaded,
|
||||
total: total_size,
|
||||
percentage,
|
||||
};
|
||||
|
||||
let _ = self.app_handle.emit("model-download-progress", &progress);
|
||||
}
|
||||
|
||||
file.flush()?;
|
||||
|
||||
// Update download status
|
||||
self.update_download_status()?;
|
||||
|
||||
// Emit completion event
|
||||
let _ = self.app_handle.emit("model-download-complete", model_id);
|
||||
|
||||
println!(
|
||||
"Successfully downloaded model {} to {:?}",
|
||||
model_id, model_path
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_model(&self, model_id: &str) -> Result<()> {
|
||||
println!("ModelManager: delete_model called for: {}", model_id);
|
||||
|
||||
let model_info = {
|
||||
let models = self.available_models.lock().unwrap();
|
||||
models.get(model_id).cloned()
|
||||
};
|
||||
|
||||
let model_info =
|
||||
model_info.ok_or_else(|| anyhow::anyhow!("Model not found: {}", model_id))?;
|
||||
|
||||
println!("ModelManager: Found model info: {:?}", model_info);
|
||||
|
||||
let model_path = self.models_dir.join(&model_info.filename);
|
||||
println!("ModelManager: Model path: {:?}", model_path);
|
||||
|
||||
if model_path.exists() {
|
||||
println!("ModelManager: Deleting model file at: {:?}", model_path);
|
||||
fs::remove_file(&model_path)?;
|
||||
println!("ModelManager: Model file deleted successfully");
|
||||
} else {
|
||||
return Err(anyhow::anyhow!("Model file not found"));
|
||||
}
|
||||
|
||||
// Update download status
|
||||
self.update_download_status()?;
|
||||
println!("ModelManager: Download status updated");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_model_path(&self, model_id: &str) -> Result<PathBuf> {
|
||||
let model_info = self
|
||||
.get_model_info(model_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Model not found: {}", model_id))?;
|
||||
|
||||
if !model_info.is_downloaded {
|
||||
return Err(anyhow::anyhow!("Model not available: {}", model_id));
|
||||
}
|
||||
|
||||
let model_path = self.models_dir.join(&model_info.filename);
|
||||
if model_path.exists() {
|
||||
Ok(model_path)
|
||||
} else {
|
||||
Err(anyhow::anyhow!("Model file not found: {}", model_id))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +1,158 @@
|
|||
use crate::managers::model::ModelManager;
|
||||
use crate::settings::get_settings;
|
||||
use anyhow::Result;
|
||||
use std::sync::Mutex;
|
||||
use tauri::{App, Manager};
|
||||
use serde::Serialize;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tauri::{App, AppHandle, Emitter, Manager};
|
||||
use whisper_rs::install_whisper_log_trampoline;
|
||||
use whisper_rs::{
|
||||
FullParams, SamplingStrategy, WhisperContext, WhisperContextParameters, WhisperState,
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct ModelStateEvent {
|
||||
pub event_type: String,
|
||||
pub model_id: Option<String>,
|
||||
pub model_name: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
pub struct TranscriptionManager {
|
||||
state: Mutex<WhisperState>,
|
||||
state: Mutex<Option<WhisperState>>,
|
||||
context: Mutex<Option<WhisperContext>>,
|
||||
model_manager: Arc<ModelManager>,
|
||||
app_handle: AppHandle,
|
||||
current_model_id: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
impl TranscriptionManager {
|
||||
pub fn new(app: &App) -> Result<Self> {
|
||||
let whisper_path = app.path().resolve(
|
||||
"resources/models/ggml-small.bin",
|
||||
tauri::path::BaseDirectory::Resource,
|
||||
)?;
|
||||
let path = whisper_path
|
||||
pub fn new(app: &App, model_manager: Arc<ModelManager>) -> Result<Self> {
|
||||
let app_handle = app.app_handle().clone();
|
||||
|
||||
let manager = Self {
|
||||
state: Mutex::new(None),
|
||||
context: Mutex::new(None),
|
||||
model_manager,
|
||||
app_handle: app_handle.clone(),
|
||||
current_model_id: Mutex::new(None),
|
||||
};
|
||||
|
||||
// Try to load the default model from settings, but don't fail if no models are available
|
||||
let settings = get_settings(&app_handle);
|
||||
let _ = manager.load_model(&settings.selected_model);
|
||||
|
||||
Ok(manager)
|
||||
}
|
||||
|
||||
pub fn load_model(&self, model_id: &str) -> Result<()> {
|
||||
// Emit loading started event
|
||||
let _ = self.app_handle.emit(
|
||||
"model-state-changed",
|
||||
ModelStateEvent {
|
||||
event_type: "loading_started".to_string(),
|
||||
model_id: Some(model_id.to_string()),
|
||||
model_name: None,
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
|
||||
let model_info = self
|
||||
.model_manager
|
||||
.get_model_info(model_id)
|
||||
.ok_or_else(|| anyhow::anyhow!("Model not found: {}", model_id))?;
|
||||
|
||||
if !model_info.is_downloaded {
|
||||
let error_msg = "Model not downloaded";
|
||||
let _ = self.app_handle.emit(
|
||||
"model-state-changed",
|
||||
ModelStateEvent {
|
||||
event_type: "loading_failed".to_string(),
|
||||
model_id: Some(model_id.to_string()),
|
||||
model_name: Some(model_info.name.clone()),
|
||||
error: Some(error_msg.to_string()),
|
||||
},
|
||||
);
|
||||
return Err(anyhow::anyhow!(error_msg));
|
||||
}
|
||||
|
||||
let model_path = self.model_manager.get_model_path(model_id)?;
|
||||
|
||||
let path_str = model_path
|
||||
.to_str()
|
||||
.expect("Path contains invalid UTF-8 Chars");
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid path for model: {}", model_id))?;
|
||||
|
||||
println!(
|
||||
"Loading transcription model {} from: {}",
|
||||
model_id, path_str
|
||||
);
|
||||
|
||||
// Install log trampoline once per model load (safe to call multiple times)
|
||||
install_whisper_log_trampoline();
|
||||
// Load the model
|
||||
let context = WhisperContext::new_with_params(path, WhisperContextParameters::default())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to load whisper model: {}", e))?;
|
||||
|
||||
// Create state
|
||||
let state = context.create_state().expect("failed to create state");
|
||||
// Create new context
|
||||
let context =
|
||||
WhisperContext::new_with_params(path_str, WhisperContextParameters::default())
|
||||
.map_err(|e| {
|
||||
let error_msg = format!("Failed to load whisper model {}: {}", model_id, e);
|
||||
let _ = self.app_handle.emit(
|
||||
"model-state-changed",
|
||||
ModelStateEvent {
|
||||
event_type: "loading_failed".to_string(),
|
||||
model_id: Some(model_id.to_string()),
|
||||
model_name: Some(model_info.name.clone()),
|
||||
error: Some(error_msg.clone()),
|
||||
},
|
||||
);
|
||||
anyhow::anyhow!(error_msg)
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
state: Mutex::new(state),
|
||||
})
|
||||
// Create new state
|
||||
let state = context.create_state().map_err(|e| {
|
||||
let error_msg = format!("Failed to create state for model {}: {}", model_id, e);
|
||||
let _ = self.app_handle.emit(
|
||||
"model-state-changed",
|
||||
ModelStateEvent {
|
||||
event_type: "loading_failed".to_string(),
|
||||
model_id: Some(model_id.to_string()),
|
||||
model_name: Some(model_info.name.clone()),
|
||||
error: Some(error_msg.clone()),
|
||||
},
|
||||
);
|
||||
anyhow::anyhow!(error_msg)
|
||||
})?;
|
||||
|
||||
// Update the current context and state
|
||||
{
|
||||
let mut current_context = self.context.lock().unwrap();
|
||||
*current_context = Some(context);
|
||||
}
|
||||
{
|
||||
let mut current_state = self.state.lock().unwrap();
|
||||
*current_state = Some(state);
|
||||
}
|
||||
{
|
||||
let mut current_model = self.current_model_id.lock().unwrap();
|
||||
*current_model = Some(model_id.to_string());
|
||||
}
|
||||
|
||||
// Emit loading completed event
|
||||
let _ = self.app_handle.emit(
|
||||
"model-state-changed",
|
||||
ModelStateEvent {
|
||||
event_type: "loading_completed".to_string(),
|
||||
model_id: Some(model_id.to_string()),
|
||||
model_name: Some(model_info.name.clone()),
|
||||
error: None,
|
||||
},
|
||||
);
|
||||
|
||||
println!("Successfully loaded transcription model: {}", model_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_current_model(&self) -> Option<String> {
|
||||
let current_model = self.current_model_id.lock().unwrap();
|
||||
current_model.clone()
|
||||
}
|
||||
|
||||
pub fn transcribe(&self, audio: Vec<f32>) -> Result<String> {
|
||||
|
|
@ -41,11 +163,16 @@ impl TranscriptionManager {
|
|||
|
||||
if audio.len() == 0 {
|
||||
println!("Empty audio vector");
|
||||
// TODO error
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
let mut state = self.state.lock().unwrap();
|
||||
let mut state_guard = self.state.lock().unwrap();
|
||||
let state = state_guard.as_mut().ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No model loaded. Please download and select a model from settings first."
|
||||
)
|
||||
})?;
|
||||
|
||||
// Initialize parameters
|
||||
let mut params = FullParams::new(SamplingStrategy::default());
|
||||
params.set_language(Some("auto"));
|
||||
|
|
|
|||
|
|
@ -18,6 +18,14 @@ pub struct AppSettings {
|
|||
pub bindings: HashMap<String, ShortcutBinding>,
|
||||
pub push_to_talk: bool,
|
||||
pub audio_feedback: bool,
|
||||
#[serde(default = "default_model")]
|
||||
pub selected_model: String,
|
||||
}
|
||||
|
||||
fn default_model() -> String {
|
||||
// Default to empty string if no models are available yet
|
||||
// The UI will handle prompting for model download
|
||||
"".to_string()
|
||||
}
|
||||
|
||||
pub const SETTINGS_STORE_PATH: &str = "settings_store.json";
|
||||
|
|
@ -49,6 +57,7 @@ pub fn get_default_settings() -> AppSettings {
|
|||
bindings,
|
||||
push_to_talk: true,
|
||||
audio_feedback: false,
|
||||
selected_model: "".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
36
src/App.tsx
36
src/App.tsx
|
|
@ -3,8 +3,44 @@ import { Settings } from "./components/settings/Settings";
|
|||
import HandyTextLogo from "./components/icons/HandyTextLogo";
|
||||
import AccessibilityPermissions from "./components/AccessibilityPermissions";
|
||||
import Footer from "./components/footer";
|
||||
import Onboarding from "./components/onboarding";
|
||||
import { useState, useEffect } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
function App() {
|
||||
const [showOnboarding, setShowOnboarding] = useState<boolean | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
checkOnboardingStatus();
|
||||
}, []);
|
||||
|
||||
const checkOnboardingStatus = async () => {
|
||||
try {
|
||||
// Always check if they have any models available
|
||||
const modelsAvailable: boolean = await invoke("has_any_models_available");
|
||||
setShowOnboarding(!modelsAvailable);
|
||||
} catch (error) {
|
||||
console.error("Failed to check onboarding status:", error);
|
||||
setShowOnboarding(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleModelSelected = () => {
|
||||
// Transition to main app - user has started a download
|
||||
setShowOnboarding(false);
|
||||
};
|
||||
|
||||
if (showOnboarding) {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col w-full">
|
||||
<div className="flex flex-col items-center pt-6 gap-8 px-4 flex-1">
|
||||
<HandyTextLogo width={300} />
|
||||
<Onboarding onModelSelected={handleModelSelected} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col w-full">
|
||||
<div className="flex flex-col items-center pt-6 gap-8 px-4 flex-1">
|
||||
|
|
|
|||
|
|
@ -1,21 +1,11 @@
|
|||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { check } from "@tauri-apps/plugin-updater";
|
||||
import { relaunch } from "@tauri-apps/plugin-process";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
import ModelSelector from "../model-selector";
|
||||
import UpdateChecker from "../update-checker";
|
||||
|
||||
const Footer: React.FC = () => {
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||
const [isInstalling, setIsInstalling] = useState(false);
|
||||
const [downloadProgress, setDownloadProgress] = useState(0);
|
||||
const [version, setVersion] = useState("");
|
||||
const [showUpToDate, setShowUpToDate] = useState(false);
|
||||
|
||||
const upToDateTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const isManualCheckRef = useRef(false);
|
||||
const downloadedBytesRef = useRef(0);
|
||||
const contentLengthRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchVersion = async () => {
|
||||
|
|
@ -29,155 +19,20 @@ const Footer: React.FC = () => {
|
|||
};
|
||||
|
||||
fetchVersion();
|
||||
checkForUpdates();
|
||||
|
||||
const unlisten = listen("check-for-updates", () => {
|
||||
handleManualUpdateCheck();
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (upToDateTimeoutRef.current) {
|
||||
clearTimeout(upToDateTimeoutRef.current);
|
||||
}
|
||||
unlisten.then((fn) => fn());
|
||||
};
|
||||
}, []);
|
||||
|
||||
const checkForUpdates = async () => {
|
||||
if (isChecking) return;
|
||||
|
||||
try {
|
||||
setIsChecking(true);
|
||||
const update = await check();
|
||||
|
||||
if (update) {
|
||||
setUpdateAvailable(true);
|
||||
setShowUpToDate(false);
|
||||
} else {
|
||||
setUpdateAvailable(false);
|
||||
|
||||
if (isManualCheckRef.current) {
|
||||
setShowUpToDate(true);
|
||||
if (upToDateTimeoutRef.current) {
|
||||
clearTimeout(upToDateTimeoutRef.current);
|
||||
}
|
||||
upToDateTimeoutRef.current = setTimeout(() => {
|
||||
setShowUpToDate(false);
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check for updates:", error);
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
isManualCheckRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleManualUpdateCheck = () => {
|
||||
isManualCheckRef.current = true;
|
||||
checkForUpdates();
|
||||
};
|
||||
|
||||
const installUpdate = async () => {
|
||||
try {
|
||||
setIsInstalling(true);
|
||||
setDownloadProgress(0);
|
||||
downloadedBytesRef.current = 0;
|
||||
contentLengthRef.current = 0;
|
||||
const update = await check();
|
||||
|
||||
if (!update) {
|
||||
console.log("No update available during install attempt");
|
||||
return;
|
||||
}
|
||||
|
||||
await update.downloadAndInstall((event) => {
|
||||
switch (event.event) {
|
||||
case "Started":
|
||||
downloadedBytesRef.current = 0;
|
||||
contentLengthRef.current = event.data.contentLength ?? 0;
|
||||
break;
|
||||
case "Progress":
|
||||
downloadedBytesRef.current += event.data.chunkLength;
|
||||
const progress =
|
||||
contentLengthRef.current > 0
|
||||
? Math.round(
|
||||
(downloadedBytesRef.current / contentLengthRef.current) *
|
||||
100,
|
||||
)
|
||||
: 0;
|
||||
setDownloadProgress(Math.min(progress, 100));
|
||||
break;
|
||||
}
|
||||
});
|
||||
await relaunch();
|
||||
} catch (error) {
|
||||
console.error("Failed to install update:", error);
|
||||
} finally {
|
||||
setIsInstalling(false);
|
||||
setDownloadProgress(0);
|
||||
downloadedBytesRef.current = 0;
|
||||
contentLengthRef.current = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = () => {
|
||||
if (isInstalling) {
|
||||
return downloadProgress > 0 && downloadProgress < 100
|
||||
? `Downloading... ${downloadProgress.toString().padStart(3)}%`
|
||||
: downloadProgress === 100
|
||||
? "Installing..."
|
||||
: "Preparing...";
|
||||
}
|
||||
if (isChecking) return "Checking...";
|
||||
if (showUpToDate) return "Up to date";
|
||||
if (updateAvailable) return "Update available";
|
||||
return "Check for updates";
|
||||
};
|
||||
|
||||
const getStatusAction = () => {
|
||||
if (updateAvailable && !isInstalling) return installUpdate;
|
||||
if (!isChecking && !isInstalling && !updateAvailable)
|
||||
return handleManualUpdateCheck;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const isDisabled = isChecking || isInstalling;
|
||||
const isClickable =
|
||||
!isDisabled && (updateAvailable || (!isChecking && !showUpToDate));
|
||||
|
||||
return (
|
||||
<div className="w-full border-t border-mid-gray/20 mt-4 pt-3">
|
||||
<div className="flex justify-between items-center text-xs px-4 pb-3 text-text/60">
|
||||
<div className="flex items-center">
|
||||
<span>v{version}</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<ModelSelector />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{isClickable ? (
|
||||
<button
|
||||
onClick={getStatusAction()}
|
||||
disabled={isDisabled}
|
||||
className={`transition-colors disabled:opacity-50 tabular-nums ${
|
||||
updateAvailable
|
||||
? "text-logo-primary hover:text-logo-primary/80 font-medium"
|
||||
: "text-text/60 hover:text-text/80"
|
||||
}`}
|
||||
>
|
||||
{getStatusText()}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-text/60 tabular-nums">{getStatusText()}</span>
|
||||
)}
|
||||
|
||||
{isInstalling && downloadProgress > 0 && downloadProgress < 100 && (
|
||||
<progress
|
||||
value={downloadProgress}
|
||||
max={100}
|
||||
className="w-20 h-2 [&::-webkit-progress-bar]:rounded-full [&::-webkit-progress-bar]:bg-mid-gray/20 [&::-webkit-progress-value]:rounded-full [&::-webkit-progress-value]:bg-logo-primary"
|
||||
/>
|
||||
)}
|
||||
{/* Update Status */}
|
||||
<div className="flex items-center gap-1">
|
||||
<UpdateChecker />
|
||||
<span>•</span>
|
||||
<span>v{version}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
604
src/components/model-selector/ModelSelector.tsx
Normal file
604
src/components/model-selector/ModelSelector.tsx
Normal file
|
|
@ -0,0 +1,604 @@
|
|||
import React, { useState, useRef, useEffect } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { ModelInfo } from "../../lib/types";
|
||||
|
||||
interface ModelStateEvent {
|
||||
event_type: string;
|
||||
model_id?: string;
|
||||
model_name?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface DownloadProgress {
|
||||
model_id: string;
|
||||
downloaded: number;
|
||||
total: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
type ModelStatus = "ready" | "loading" | "downloading" | "error" | "none";
|
||||
|
||||
interface DownloadStats {
|
||||
startTime: number;
|
||||
lastUpdate: number;
|
||||
totalDownloaded: number;
|
||||
speed: number;
|
||||
}
|
||||
|
||||
interface ModelSelectorProps {
|
||||
onError?: (error: string) => void;
|
||||
}
|
||||
|
||||
const ModelSelector: React.FC<ModelSelectorProps> = ({ onError }) => {
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const [currentModelId, setCurrentModelId] = useState<string>("");
|
||||
const [modelStatus, setModelStatus] = useState<ModelStatus>("loading");
|
||||
const [modelError, setModelError] = useState<string | null>(null);
|
||||
const [modelDownloadProgress, setModelDownloadProgress] = useState<
|
||||
Map<string, DownloadProgress>
|
||||
>(new Map());
|
||||
const [showModelDropdown, setShowModelDropdown] = useState(false);
|
||||
const [downloadStats, setDownloadStats] = useState<
|
||||
Map<string, DownloadStats>
|
||||
>(new Map());
|
||||
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadModels();
|
||||
loadCurrentModel();
|
||||
|
||||
// Listen for model state changes
|
||||
const modelStateUnlisten = listen<ModelStateEvent>(
|
||||
"model-state-changed",
|
||||
(event) => {
|
||||
const { event_type, model_id, model_name, error } = event.payload;
|
||||
|
||||
switch (event_type) {
|
||||
case "loading_started":
|
||||
setModelStatus("loading");
|
||||
setModelError(null);
|
||||
break;
|
||||
case "loading_completed":
|
||||
setModelStatus("ready");
|
||||
setModelError(null);
|
||||
if (model_id) setCurrentModelId(model_id);
|
||||
break;
|
||||
case "loading_failed":
|
||||
setModelStatus("error");
|
||||
setModelError(error || "Failed to load model");
|
||||
break;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Listen for model download progress
|
||||
const downloadProgressUnlisten = listen<DownloadProgress>(
|
||||
"model-download-progress",
|
||||
(event) => {
|
||||
const progress = event.payload;
|
||||
setModelDownloadProgress((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.set(progress.model_id, progress);
|
||||
return newMap;
|
||||
});
|
||||
setModelStatus("downloading");
|
||||
|
||||
// Update download stats for speed calculation
|
||||
const now = Date.now();
|
||||
setDownloadStats((prev) => {
|
||||
const current = prev.get(progress.model_id);
|
||||
const newStats = new Map(prev);
|
||||
|
||||
if (!current) {
|
||||
// First progress update - initialize
|
||||
newStats.set(progress.model_id, {
|
||||
startTime: now,
|
||||
lastUpdate: now,
|
||||
totalDownloaded: progress.downloaded,
|
||||
speed: 0,
|
||||
});
|
||||
} else {
|
||||
// Calculate speed over last few seconds
|
||||
const timeDiff = (now - current.lastUpdate) / 1000; // seconds
|
||||
const bytesDiff = progress.downloaded - current.totalDownloaded;
|
||||
|
||||
if (timeDiff > 0.5) {
|
||||
// Update speed every 500ms
|
||||
const currentSpeed = bytesDiff / (1024 * 1024) / timeDiff; // MB/s
|
||||
// Smooth the speed with exponential moving average, but ensure positive values
|
||||
const validCurrentSpeed = Math.max(0, currentSpeed);
|
||||
const smoothedSpeed =
|
||||
current.speed > 0
|
||||
? current.speed * 0.8 + validCurrentSpeed * 0.2
|
||||
: validCurrentSpeed;
|
||||
|
||||
newStats.set(progress.model_id, {
|
||||
startTime: current.startTime,
|
||||
lastUpdate: now,
|
||||
totalDownloaded: progress.downloaded,
|
||||
speed: Math.max(0, smoothedSpeed),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return newStats;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Listen for model download completion
|
||||
const downloadCompleteUnlisten = listen<string>(
|
||||
"model-download-complete",
|
||||
(event) => {
|
||||
const modelId = event.payload;
|
||||
setModelDownloadProgress((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
newMap.delete(modelId);
|
||||
return newMap;
|
||||
});
|
||||
setDownloadStats((prev) => {
|
||||
const newStats = new Map(prev);
|
||||
newStats.delete(modelId);
|
||||
return newStats;
|
||||
});
|
||||
loadModels(); // Refresh models list
|
||||
|
||||
// Auto-select the newly downloaded model
|
||||
setTimeout(() => {
|
||||
loadCurrentModel();
|
||||
handleModelSelect(modelId);
|
||||
}, 500);
|
||||
},
|
||||
);
|
||||
|
||||
// Click outside to close dropdown
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setShowModelDropdown(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
modelStateUnlisten.then((fn) => fn());
|
||||
downloadProgressUnlisten.then((fn) => fn());
|
||||
downloadCompleteUnlisten.then((fn) => fn());
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadModels = async () => {
|
||||
try {
|
||||
const modelList = await invoke<ModelInfo[]>("get_available_models");
|
||||
setModels(modelList);
|
||||
} catch (err) {
|
||||
console.error("Failed to load models:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const loadCurrentModel = async () => {
|
||||
try {
|
||||
const current = await invoke<string>("get_current_model");
|
||||
setCurrentModelId(current);
|
||||
|
||||
if (current) {
|
||||
// Check if model is actually loaded
|
||||
const transcriptionStatus = await invoke<string | null>(
|
||||
"get_transcription_model_status",
|
||||
);
|
||||
if (transcriptionStatus === current) {
|
||||
setModelStatus("ready");
|
||||
} else {
|
||||
setModelStatus("loading");
|
||||
}
|
||||
} else {
|
||||
setModelStatus("none");
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load current model:", err);
|
||||
setModelStatus("error");
|
||||
setModelError("Failed to check model status");
|
||||
}
|
||||
};
|
||||
|
||||
const handleModelSelect = async (modelId: string) => {
|
||||
try {
|
||||
setModelError(null);
|
||||
setShowModelDropdown(false);
|
||||
await invoke("set_active_model", { modelId });
|
||||
setCurrentModelId(modelId);
|
||||
} catch (err) {
|
||||
const errorMsg = `${err}`;
|
||||
setModelError(errorMsg);
|
||||
setModelStatus("error");
|
||||
onError?.(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleModelDownload = async (modelId: string) => {
|
||||
try {
|
||||
setModelError(null);
|
||||
await invoke("download_model", { modelId });
|
||||
} catch (err) {
|
||||
const errorMsg = `${err}`;
|
||||
setModelError(errorMsg);
|
||||
setModelStatus("error");
|
||||
onError?.(errorMsg);
|
||||
}
|
||||
};
|
||||
|
||||
const getModelStatusColor = (status: ModelStatus): string => {
|
||||
switch (status) {
|
||||
case "ready":
|
||||
return "bg-green-400";
|
||||
case "loading":
|
||||
return "bg-yellow-400 animate-pulse";
|
||||
case "downloading":
|
||||
return "bg-logo-primary animate-pulse";
|
||||
case "error":
|
||||
return "bg-red-400";
|
||||
case "none":
|
||||
return "bg-red-400";
|
||||
default:
|
||||
return "bg-mid-gray/60";
|
||||
}
|
||||
};
|
||||
|
||||
const getCurrentModel = () => {
|
||||
return models.find((m) => m.id === currentModelId);
|
||||
};
|
||||
|
||||
const getModelDisplayText = (): string => {
|
||||
if (modelDownloadProgress.size > 0) {
|
||||
if (modelDownloadProgress.size === 1) {
|
||||
const [progress] = Array.from(modelDownloadProgress.values());
|
||||
const percentage = Math.max(
|
||||
0,
|
||||
Math.min(100, Math.round(progress.percentage)),
|
||||
);
|
||||
return `Downloading ${percentage}%`;
|
||||
} else {
|
||||
return `Downloading ${modelDownloadProgress.size} models...`;
|
||||
}
|
||||
}
|
||||
|
||||
const currentModel = getCurrentModel();
|
||||
|
||||
switch (modelStatus) {
|
||||
case "ready":
|
||||
return currentModel?.name || "Model Ready";
|
||||
case "loading":
|
||||
return currentModel ? `Loading ${currentModel.name}...` : "Loading...";
|
||||
case "error":
|
||||
return modelError || "Model Error";
|
||||
case "none":
|
||||
return "No Model - Download Required";
|
||||
default:
|
||||
return currentModel?.name || "Select Model";
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (sizeMb: number): string => {
|
||||
if (sizeMb < 1024) return `${sizeMb}MB`;
|
||||
return `${(sizeMb / 1024).toFixed(1)}GB`;
|
||||
};
|
||||
|
||||
const getAvailableModels = () => {
|
||||
return models.filter((m) => m.is_downloaded);
|
||||
};
|
||||
|
||||
const getDownloadableModels = () => {
|
||||
return models.filter((m) => !m.is_downloaded);
|
||||
};
|
||||
|
||||
const availableModels = getAvailableModels();
|
||||
const downloadableModels = getDownloadableModels();
|
||||
const isFirstRun = availableModels.length === 0 && models.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Model Status and Switcher */}
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
onClick={() => setShowModelDropdown(!showModelDropdown)}
|
||||
className="flex items-center gap-2 hover:text-text/80 transition-colors"
|
||||
title={`Model status: ${getModelDisplayText()}`}
|
||||
>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${getModelStatusColor(modelStatus)}`}
|
||||
/>
|
||||
<span className="max-w-28 truncate">{getModelDisplayText()}</span>
|
||||
<svg
|
||||
className={`w-3 h-3 transition-transform ${showModelDropdown ? "rotate-180" : ""}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Model Dropdown */}
|
||||
{showModelDropdown && (
|
||||
<div className="absolute bottom-full left-0 mb-2 w-64 bg-background border border-mid-gray/20 rounded-lg shadow-lg py-2 z-50">
|
||||
{/* First Run Welcome */}
|
||||
{isFirstRun && (
|
||||
<div className="px-3 py-2 bg-logo-primary/10 border-b border-logo-primary/20">
|
||||
<div className="text-xs font-medium text-logo-primary mb-1">
|
||||
Welcome to Handy!
|
||||
</div>
|
||||
<div className="text-xs text-text/70">
|
||||
Download a model below to get started with transcription.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Available Models */}
|
||||
{availableModels.length > 0 && (
|
||||
<div>
|
||||
<div className="px-3 py-1 text-xs font-medium text-text/80 border-b border-mid-gray/10">
|
||||
Available Models
|
||||
</div>
|
||||
{availableModels.map((model) => (
|
||||
<div
|
||||
key={model.id}
|
||||
onClick={() => handleModelSelect(model.id)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleModelSelect(model.id);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
className={`w-full px-3 py-2 text-left hover:bg-mid-gray/10 transition-colors cursor-pointer focus:outline-none ${
|
||||
currentModelId === model.id
|
||||
? "bg-logo-primary/10 text-logo-primary"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm">{model.name}</div>
|
||||
<div className="text-xs text-text/40 italic pr-4">
|
||||
{model.description}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{currentModelId === model.id && (
|
||||
<div className="text-xs text-logo-primary">
|
||||
Active
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
const shouldShowDelete = currentModelId !== model.id;
|
||||
return shouldShowDelete;
|
||||
})() && (
|
||||
<button
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
console.log(
|
||||
"Delete button clicked for model:",
|
||||
model.id,
|
||||
);
|
||||
console.log("Model info:", model);
|
||||
|
||||
try {
|
||||
console.log("Deleting model:", model.id);
|
||||
|
||||
const result = await invoke("delete_model", {
|
||||
modelId: model.id,
|
||||
});
|
||||
|
||||
console.log("Delete result:", result);
|
||||
|
||||
// Refresh models list but keep dropdown open
|
||||
await loadModels();
|
||||
setModelError(null);
|
||||
|
||||
console.log("Model deleted and UI refreshed");
|
||||
} catch (err) {
|
||||
console.error("Delete failed with error:", err);
|
||||
const errorMsg = `Failed to delete model: ${err}`;
|
||||
setModelError(errorMsg);
|
||||
setModelStatus("error");
|
||||
onError?.(errorMsg);
|
||||
}
|
||||
}}
|
||||
className="text-red-400 hover:text-red-300 p-1 hover:bg-red-500/10 rounded transition-colors"
|
||||
title={`Delete ${model.name}`}
|
||||
>
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Downloadable Models */}
|
||||
{downloadableModels.length > 0 && (
|
||||
<div>
|
||||
{(availableModels.length > 0 || isFirstRun) && (
|
||||
<div className="border-t border-mid-gray/10 my-1" />
|
||||
)}
|
||||
<div className="px-3 py-1 text-xs font-medium text-text/80">
|
||||
{isFirstRun ? "Choose a Model" : "Download Models"}
|
||||
</div>
|
||||
{downloadableModels.map((model) => (
|
||||
<div
|
||||
key={model.id}
|
||||
onClick={(e) => {
|
||||
if (modelDownloadProgress.has(model.id)) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
handleModelDownload(model.id);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
if (modelDownloadProgress.has(model.id)) {
|
||||
return;
|
||||
}
|
||||
handleModelDownload(model.id);
|
||||
}
|
||||
}}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-disabled={modelDownloadProgress.has(model.id)}
|
||||
className={`w-full px-3 py-2 text-left hover:bg-mid-gray/10 transition-colors cursor-pointer focus:outline-none ${
|
||||
modelDownloadProgress.has(model.id)
|
||||
? "opacity-50 cursor-not-allowed hover:bg-transparent"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-sm">
|
||||
{model.name}
|
||||
{model.id === "small" && isFirstRun && (
|
||||
<span className="ml-2 text-xs bg-logo-primary/20 text-logo-primary px-1.5 py-0.5 rounded">
|
||||
Recommended
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-text/40 italic pr-4">
|
||||
{model.description}
|
||||
</div>
|
||||
</div>
|
||||
{modelDownloadProgress.has(model.id) ? (
|
||||
<div className="text-xs text-logo-primary tabular-nums">
|
||||
{Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
100,
|
||||
Math.round(
|
||||
modelDownloadProgress.get(model.id)!.percentage,
|
||||
),
|
||||
),
|
||||
)}
|
||||
%
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-logo-primary">
|
||||
Download
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{modelDownloadProgress.has(model.id) && (
|
||||
<div className="mt-2">
|
||||
<div className="w-full bg-mid-gray/20 rounded-full h-1.5">
|
||||
<div
|
||||
className="h-1.5 bg-logo-primary rounded-full transition-all duration-500 ease-out"
|
||||
style={{
|
||||
width: `${Math.max(0, Math.min(100, modelDownloadProgress.get(model.id)!.percentage))}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No Models Available */}
|
||||
{availableModels.length === 0 &&
|
||||
downloadableModels.length === 0 && (
|
||||
<div className="px-3 py-2 text-sm text-text/60">
|
||||
No models available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Download Progress Bar for Models */}
|
||||
{modelDownloadProgress.size > 0 && (
|
||||
<div className="flex items-center gap-3">
|
||||
{modelDownloadProgress.size === 1 ? (
|
||||
// Single download - show detailed progress
|
||||
(() => {
|
||||
const [progress] = Array.from(modelDownloadProgress.values());
|
||||
return (
|
||||
<>
|
||||
<div className="w-16 h-1.5 bg-mid-gray/20 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-logo-primary transition-all duration-500 ease-out"
|
||||
style={{
|
||||
width: `${Math.max(0, Math.min(100, progress.percentage))}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-text/60 tabular-nums min-w-fit">
|
||||
{downloadStats.get(progress.model_id)?.speed &&
|
||||
downloadStats.get(progress.model_id)!.speed > 0 ? (
|
||||
<span>
|
||||
{downloadStats.get(progress.model_id)!.speed.toFixed(1)}
|
||||
MB/s
|
||||
</span>
|
||||
) : (
|
||||
<span>Downloading...</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
// Multiple downloads - show summary
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-1">
|
||||
{Array.from(modelDownloadProgress.values()).map(
|
||||
(progress, index) => (
|
||||
<div
|
||||
key={progress.model_id}
|
||||
className="w-3 h-1.5 bg-mid-gray/20 rounded-full overflow-hidden"
|
||||
>
|
||||
<div
|
||||
className="h-full bg-logo-primary transition-all duration-500 ease-out"
|
||||
style={{
|
||||
width: `${Math.max(0, Math.min(100, progress.percentage))}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-text/60 min-w-fit">
|
||||
{modelDownloadProgress.size} downloading...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelSelector;
|
||||
1
src/components/model-selector/index.ts
Normal file
1
src/components/model-selector/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from './ModelSelector';
|
||||
94
src/components/onboarding/Onboarding.tsx
Normal file
94
src/components/onboarding/Onboarding.tsx
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { ModelInfo } from "../../lib/types";
|
||||
|
||||
interface OnboardingProps {
|
||||
onModelSelected: () => void;
|
||||
}
|
||||
|
||||
const Onboarding: React.FC<OnboardingProps> = ({ onModelSelected }) => {
|
||||
const [availableModels, setAvailableModels] = useState<ModelInfo[]>([]);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadModels();
|
||||
}, []);
|
||||
|
||||
const loadModels = async () => {
|
||||
try {
|
||||
const models: ModelInfo[] = await invoke("get_available_models");
|
||||
// Only show downloadable models for onboarding
|
||||
setAvailableModels(models.filter((m) => !m.is_downloaded));
|
||||
} catch (err) {
|
||||
console.error("Failed to load models:", err);
|
||||
setError("Failed to load available models");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadModel = async (modelId: string) => {
|
||||
setDownloading(true);
|
||||
setError(null);
|
||||
|
||||
// Immediately transition to main app - download will continue in footer
|
||||
onModelSelected();
|
||||
|
||||
try {
|
||||
await invoke("download_model", { modelId });
|
||||
} catch (err) {
|
||||
console.error("Download failed:", err);
|
||||
setError(`Failed to download model: ${err}`);
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getRecommendedBadge = (modelId: string): boolean => {
|
||||
return modelId === "small";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto text-center space-y-8">
|
||||
<p className="text-text/70 max-w-md mx-auto">
|
||||
To get started, choose a transcription model to download
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-500/10 border border-red-500/20 rounded-lg p-4">
|
||||
<p className="text-red-400 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 sm:gap-6 max-w-2xl mx-auto">
|
||||
{availableModels
|
||||
.sort((a, b) => a.size_mb - b.size_mb)
|
||||
.map((model) => (
|
||||
<button
|
||||
key={model.id}
|
||||
onClick={() => handleDownloadModel(model.id)}
|
||||
disabled={downloading}
|
||||
className="relative border-2 border-mid-gray/20 rounded-xl p-4 sm:p-6 text-left hover:border-logo-primary/50 hover:bg-logo-primary/5 hover:shadow-lg hover:scale-[1.02] focus:border-logo-primary focus:outline-none focus:ring-2 focus:ring-logo-primary/25 active:scale-[0.98] transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:border-mid-gray/20 disabled:hover:bg-transparent disabled:hover:shadow-none disabled:hover:scale-100 cursor-pointer group"
|
||||
>
|
||||
{getRecommendedBadge(model.id) && (
|
||||
<div className="absolute -top-2 -right-2 bg-logo-primary text-white text-xs px-3 py-1 rounded-full font-medium shadow-md">
|
||||
Recommended
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-lg sm:text-xl font-semibold text-text group-hover:text-logo-primary transition-colors">
|
||||
{model.name}
|
||||
</h3>
|
||||
<p className="text-text/60 text-xs sm:text-sm leading-relaxed">
|
||||
{model.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Onboarding;
|
||||
1
src/components/onboarding/index.ts
Normal file
1
src/components/onboarding/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from './Onboarding';
|
||||
176
src/components/update-checker/UpdateChecker.tsx
Normal file
176
src/components/update-checker/UpdateChecker.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import React, { useState, useEffect, useRef } from "react";
|
||||
import { check } from "@tauri-apps/plugin-updater";
|
||||
import { relaunch } from "@tauri-apps/plugin-process";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
interface UpdateCheckerProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const UpdateChecker: React.FC<UpdateCheckerProps> = ({ className = "" }) => {
|
||||
// Update checking state
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
const [updateAvailable, setUpdateAvailable] = useState(false);
|
||||
const [isInstalling, setIsInstalling] = useState(false);
|
||||
const [downloadProgress, setDownloadProgress] = useState(0);
|
||||
const [showUpToDate, setShowUpToDate] = useState(false);
|
||||
|
||||
const upToDateTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
const isManualCheckRef = useRef(false);
|
||||
const downloadedBytesRef = useRef(0);
|
||||
const contentLengthRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
checkForUpdates();
|
||||
|
||||
// Listen for update check events
|
||||
const updateUnlisten = listen("check-for-updates", () => {
|
||||
handleManualUpdateCheck();
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (upToDateTimeoutRef.current) {
|
||||
clearTimeout(upToDateTimeoutRef.current);
|
||||
}
|
||||
updateUnlisten.then((fn) => fn());
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Update checking functions
|
||||
const checkForUpdates = async () => {
|
||||
if (isChecking) return;
|
||||
|
||||
try {
|
||||
setIsChecking(true);
|
||||
const update = await check();
|
||||
|
||||
if (update) {
|
||||
setUpdateAvailable(true);
|
||||
setShowUpToDate(false);
|
||||
} else {
|
||||
setUpdateAvailable(false);
|
||||
|
||||
if (isManualCheckRef.current) {
|
||||
setShowUpToDate(true);
|
||||
if (upToDateTimeoutRef.current) {
|
||||
clearTimeout(upToDateTimeoutRef.current);
|
||||
}
|
||||
upToDateTimeoutRef.current = setTimeout(() => {
|
||||
setShowUpToDate(false);
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check for updates:", error);
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
isManualCheckRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleManualUpdateCheck = () => {
|
||||
isManualCheckRef.current = true;
|
||||
checkForUpdates();
|
||||
};
|
||||
|
||||
const installUpdate = async () => {
|
||||
try {
|
||||
setIsInstalling(true);
|
||||
setDownloadProgress(0);
|
||||
downloadedBytesRef.current = 0;
|
||||
contentLengthRef.current = 0;
|
||||
const update = await check();
|
||||
|
||||
if (!update) {
|
||||
console.log("No update available during install attempt");
|
||||
return;
|
||||
}
|
||||
|
||||
await update.downloadAndInstall((event) => {
|
||||
switch (event.event) {
|
||||
case "Started":
|
||||
downloadedBytesRef.current = 0;
|
||||
contentLengthRef.current = event.data.contentLength ?? 0;
|
||||
break;
|
||||
case "Progress":
|
||||
downloadedBytesRef.current += event.data.chunkLength;
|
||||
const progress =
|
||||
contentLengthRef.current > 0
|
||||
? Math.round(
|
||||
(downloadedBytesRef.current / contentLengthRef.current) *
|
||||
100,
|
||||
)
|
||||
: 0;
|
||||
setDownloadProgress(Math.min(progress, 100));
|
||||
break;
|
||||
}
|
||||
});
|
||||
await relaunch();
|
||||
} catch (error) {
|
||||
console.error("Failed to install update:", error);
|
||||
} finally {
|
||||
setIsInstalling(false);
|
||||
setDownloadProgress(0);
|
||||
downloadedBytesRef.current = 0;
|
||||
contentLengthRef.current = 0;
|
||||
}
|
||||
};
|
||||
|
||||
// Update status functions
|
||||
const getUpdateStatusText = () => {
|
||||
if (isInstalling) {
|
||||
return downloadProgress > 0 && downloadProgress < 100
|
||||
? `Downloading... ${downloadProgress.toString().padStart(3)}%`
|
||||
: downloadProgress === 100
|
||||
? "Installing..."
|
||||
: "Preparing...";
|
||||
}
|
||||
if (isChecking) return "Checking...";
|
||||
if (showUpToDate) return "Up to date";
|
||||
if (updateAvailable) return "Update available";
|
||||
return "Check for updates";
|
||||
};
|
||||
|
||||
const getUpdateStatusAction = () => {
|
||||
if (updateAvailable && !isInstalling) return installUpdate;
|
||||
if (!isChecking && !isInstalling && !updateAvailable)
|
||||
return handleManualUpdateCheck;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const isUpdateDisabled = isChecking || isInstalling;
|
||||
const isUpdateClickable =
|
||||
!isUpdateDisabled && (updateAvailable || (!isChecking && !showUpToDate));
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-3 ${className}`}>
|
||||
{isUpdateClickable ? (
|
||||
<button
|
||||
onClick={getUpdateStatusAction()}
|
||||
disabled={isUpdateDisabled}
|
||||
className={`transition-colors disabled:opacity-50 tabular-nums ${
|
||||
updateAvailable
|
||||
? "text-logo-primary hover:text-logo-primary/80 font-medium"
|
||||
: "text-text/60 hover:text-text/80"
|
||||
}`}
|
||||
>
|
||||
{getUpdateStatusText()}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-text/60 tabular-nums">
|
||||
{getUpdateStatusText()}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isInstalling && downloadProgress > 0 && downloadProgress < 100 && (
|
||||
<progress
|
||||
value={downloadProgress}
|
||||
max={100}
|
||||
className="w-20 h-2 [&::-webkit-progress-bar]:rounded-full [&::-webkit-progress-bar]:bg-mid-gray/20 [&::-webkit-progress-value]:rounded-full [&::-webkit-progress-value]:bg-logo-primary"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UpdateChecker;
|
||||
1
src/components/update-checker/index.ts
Normal file
1
src/components/update-checker/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from './UpdateChecker';
|
||||
186
src/hooks/useModels.ts
Normal file
186
src/hooks/useModels.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
interface ModelInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
filename: string;
|
||||
url?: string;
|
||||
size_mb: number;
|
||||
is_downloaded: boolean;
|
||||
is_bundled: boolean;
|
||||
}
|
||||
|
||||
interface DownloadProgress {
|
||||
model_id: string;
|
||||
downloaded: number;
|
||||
total: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
export const useModels = () => {
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
const [currentModel, setCurrentModel] = useState<string>("");
|
||||
const [downloadingModels, setDownloadingModels] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [downloadProgress, setDownloadProgress] = useState<
|
||||
Map<string, DownloadProgress>
|
||||
>(new Map());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasAnyModels, setHasAnyModels] = useState(false);
|
||||
const [isFirstRun, setIsFirstRun] = useState(false);
|
||||
|
||||
const loadModels = async () => {
|
||||
try {
|
||||
const modelList = await invoke<ModelInfo[]>("get_available_models");
|
||||
setModels(modelList);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(`Failed to load models: ${err}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadCurrentModel = async () => {
|
||||
try {
|
||||
const current = await invoke<string>("get_current_model");
|
||||
setCurrentModel(current);
|
||||
} catch (err) {
|
||||
console.error("Failed to load current model:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const checkFirstRun = async () => {
|
||||
try {
|
||||
const hasModels = await invoke<boolean>("has_any_models_available");
|
||||
setHasAnyModels(hasModels);
|
||||
setIsFirstRun(!hasModels);
|
||||
return !hasModels;
|
||||
} catch (err) {
|
||||
console.error("Failed to check model availability:", err);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const selectModel = async (modelId: string) => {
|
||||
try {
|
||||
setError(null);
|
||||
await invoke("set_active_model", { modelId });
|
||||
setCurrentModel(modelId);
|
||||
setIsFirstRun(false);
|
||||
setHasAnyModels(true);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(`Failed to switch to model: ${err}`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const downloadModel = async (modelId: string) => {
|
||||
try {
|
||||
setError(null);
|
||||
setDownloadingModels((prev) => new Set(prev.add(modelId)));
|
||||
await invoke("download_model", { modelId });
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(`Failed to download model: ${err}`);
|
||||
setDownloadingModels((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(modelId);
|
||||
return next;
|
||||
});
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteModel = async (modelId: string) => {
|
||||
try {
|
||||
setError(null);
|
||||
await invoke("delete_model", { modelId });
|
||||
await loadModels(); // Refresh the list
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(`Failed to delete model: ${err}`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const getModelInfo = (modelId: string): ModelInfo | undefined => {
|
||||
return models.find((model) => model.id === modelId);
|
||||
};
|
||||
|
||||
const isModelDownloading = (modelId: string): boolean => {
|
||||
return downloadingModels.has(modelId);
|
||||
};
|
||||
|
||||
const getDownloadProgress = (
|
||||
modelId: string,
|
||||
): DownloadProgress | undefined => {
|
||||
return downloadProgress.get(modelId);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadModels();
|
||||
loadCurrentModel();
|
||||
checkFirstRun();
|
||||
|
||||
// Listen for download progress
|
||||
const progressUnlisten = listen<DownloadProgress>(
|
||||
"model-download-progress",
|
||||
(event) => {
|
||||
setDownloadProgress(
|
||||
(prev) => new Map(prev.set(event.payload.model_id, event.payload)),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Listen for download completion
|
||||
const completeUnlisten = listen<string>(
|
||||
"model-download-complete",
|
||||
(event) => {
|
||||
const modelId = event.payload;
|
||||
setDownloadingModels((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(modelId);
|
||||
return next;
|
||||
});
|
||||
setDownloadProgress((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(modelId);
|
||||
return next;
|
||||
});
|
||||
// Refresh models list to update download status
|
||||
loadModels();
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
progressUnlisten.then((fn) => fn());
|
||||
completeUnlisten.then((fn) => fn());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
models,
|
||||
currentModel,
|
||||
loading,
|
||||
error,
|
||||
downloadingModels,
|
||||
downloadProgress,
|
||||
hasAnyModels,
|
||||
isFirstRun,
|
||||
loadModels,
|
||||
loadCurrentModel,
|
||||
checkFirstRun,
|
||||
selectModel,
|
||||
downloadModel,
|
||||
deleteModel,
|
||||
getModelInfo,
|
||||
isModelDownloading,
|
||||
getDownloadProgress,
|
||||
};
|
||||
};
|
||||
|
|
@ -29,3 +29,13 @@ export type BindingResponse = z.infer<typeof BindingResponseSchema>;
|
|||
export type ShortcutBinding = z.infer<typeof ShortcutBindingSchema>;
|
||||
export type ShortcutBindingsMap = z.infer<typeof ShortcutBindingsMapSchema>;
|
||||
export type Settings = z.infer<typeof SettingsSchema>;
|
||||
|
||||
export const ModelInfoSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
size_mb: z.number(),
|
||||
is_downloaded: z.boolean(),
|
||||
});
|
||||
|
||||
export type ModelInfo = z.infer<typeof ModelInfoSchema>;
|
||||
|
|
|
|||
Loading…
Reference in a new issue