Add resumable background model downloads

This commit is contained in:
Georg Bauer
2026-07-24 13:10:04 +02:00
parent 62e7752ec7
commit 952fb79edc
12 changed files with 1265 additions and 115 deletions

206
Cargo.lock generated
View File

@@ -359,6 +359,15 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]] [[package]]
name = "block2" name = "block2"
version = "0.5.1" version = "0.5.1"
@@ -614,6 +623,12 @@ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
[[package]]
name = "const-oid"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.9.4" version = "0.9.4"
@@ -686,6 +701,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "cpufeatures"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "crc32fast" name = "crc32fast"
version = "1.5.0" version = "1.5.0"
@@ -736,6 +760,15 @@ dependencies = [
"typenum", "typenum",
] ]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]] [[package]]
name = "ctor" name = "ctor"
version = "0.10.1" version = "0.10.1"
@@ -889,8 +922,19 @@ version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [ dependencies = [
"block-buffer", "block-buffer 0.10.4",
"crypto-common", "crypto-common 0.1.7",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.1",
"const-oid",
"crypto-common 0.2.2",
] ]
[[package]] [[package]]
@@ -1023,6 +1067,8 @@ dependencies = [
"diesel_migrations", "diesel_migrations",
"iced", "iced",
"rfd", "rfd",
"sha2",
"ureq",
] ]
[[package]] [[package]]
@@ -1626,6 +1672,31 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
[[package]]
name = "http"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "hybrid-array"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c"
dependencies = [
"typenum",
]
[[package]] [[package]]
name = "iced" name = "iced"
version = "0.13.1" version = "0.13.1"
@@ -1940,6 +2011,12 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]] [[package]]
name = "jni" name = "jni"
version = "0.22.4" version = "0.22.4"
@@ -3215,6 +3292,20 @@ dependencies = [
"bytemuck", "bytemuck",
] ]
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "roxmltree" name = "roxmltree"
version = "0.20.0" version = "0.20.0"
@@ -3288,6 +3379,41 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "rustls"
version = "0.23.42"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
dependencies = [
"log",
"once_cell",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-pki-types"
version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.103.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
]
[[package]] [[package]]
name = "rustversion" name = "rustversion"
version = "1.0.23" version = "1.0.23"
@@ -3414,8 +3540,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures 0.2.17",
"digest", "digest 0.10.7",
]
[[package]]
name = "sha2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures 0.3.0",
"digest 0.11.3",
] ]
[[package]] [[package]]
@@ -3654,6 +3791,12 @@ version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "svg_fmt" name = "svg_fmt"
version = "0.4.5" version = "0.4.5"
@@ -4075,6 +4218,40 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "ureq"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0"
dependencies = [
"base64",
"log",
"percent-encoding",
"rustls",
"rustls-pki-types",
"ureq-proto",
"utf8-zero",
"webpki-roots",
]
[[package]]
name = "ureq-proto"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c"
dependencies = [
"base64",
"http",
"httparse",
"log",
]
[[package]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@@ -4121,6 +4298,12 @@ dependencies = [
"xmlwriter", "xmlwriter",
] ]
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]] [[package]]
name = "utf8_iter" name = "utf8_iter"
version = "1.0.4" version = "1.0.4"
@@ -4400,6 +4583,15 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "webpki-roots"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "weezl" name = "weezl"
version = "0.1.12" version = "0.1.12"
@@ -5046,6 +5238,12 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"
version = "0.2.4" version = "0.2.4"

View File

@@ -12,6 +12,8 @@ diesel = { version = "2.3.11", features = ["sqlite", "returning_clauses_for_sqli
diesel_migrations = "2.3.2" diesel_migrations = "2.3.2"
iced = { version = "0.13.1", features = ["svg"] } iced = { version = "0.13.1", features = ["svg"] }
rfd = "0.15.4" rfd = "0.15.4"
sha2 = "0.11.0"
ureq = { version = "3.3.0", default-features = false, features = ["rustls"] }
[package.metadata.packager] [package.metadata.packager]
product-name = "DS4Server" product-name = "DS4Server"

138
PLAN.md
View File

@@ -15,15 +15,16 @@ sessions, tools, and turn orchestration.
| App shell | Implemented | Native Iced window, Codex-inspired two-pane layout, dark theme, embedded SVG icons, `Command-,` Preferences panel | Functional chat content and native app packaging | | App shell | Implemented | Native Iced window, Codex-inspired two-pane layout, dark theme, embedded SVG icons, `Command-,` Preferences panel | Functional chat content and native app packaging |
| Projects and sessions | Implemented for metadata | Native folder picker, project-name dialog, create/select/delete projects and sessions | Transcripts, KV payloads, rename/archive, and model binding | | Projects and sessions | Implemented for metadata | Native folder picker, project-name dialog, create/select/delete projects and sessions | Transcripts, KV payloads, rename/archive, and model binding |
| Persistence | Implemented for metadata and preferences | SQLite in Application Support, Diesel ORM, embedded migrations, constrained defaults and CRUD tests | Messages, downloaded-artifact metadata, and session-runtime migrations | | Persistence | Implemented for metadata and preferences | SQLite in Application Support, Diesel ORM, embedded migrations, constrained defaults and CRUD tests | Messages, downloaded-artifact metadata, and session-runtime migrations |
| Model runtime | Preferences only | Persisted model choice, DFlash toggle, and idle timeout | Downloads, loading, inference, idle unloading, and Metal integration | | Model runtime | Download workflow implemented | Persisted model choice, DSpark toggle, idle timeout, and Rust-native background main/DSpark downloads with progress, ETA, stop, restart-resume, size checks, and SHA-256 verification | Loading, inference, idle unloading, and Metal integration |
| Local endpoint | Not started | Nothing listening | OpenAI-compatible HTTP and streaming surfaces | | Local endpoint | Not started | Nothing listening | OpenAI-compatible HTTP and streaming surfaces |
| Agent | UI shell only | Empty conversation pane and composer placeholder | Messages, generation, tools, approvals, compaction, and cancellation | | Agent | UI shell only | Empty conversation pane and composer placeholder | Messages, generation, tools, approvals, compaction, and cancellation |
| Dev brain | Not started | No vault access | Obsidian vault selection, durable access, and agent knowledge/memory tools | | Dev brain | Not started | No vault access | Obsidian vault selection, durable access, and agent knowledge/memory tools |
| Release | Not started | Development binary builds and tests | `.app` packaging, signing, entitlements, and notarization | | Release | Not started | Development binary builds and tests | `.app` packaging, signing, entitlements, and notarization |
The application currently manages durable project/session metadata and model The application currently manages durable project/session metadata and model
preferences. It does not yet download or load a model, serve HTTP, save chat preferences, and can download the selected model's main and optional DSpark
messages, or run an agent. GGUF into managed Application Support storage without blocking the UI. It does
not yet load a model, serve HTTP, save chat messages, or run an agent.
## Phase 0 — project and session shell ## Phase 0 — project and session shell
@@ -42,8 +43,9 @@ Exit criterion: restart the app and see the same project/session tree.
## Phase 1 — model library and on-demand loading ## Phase 1 — model library and on-demand loading
Status: **partially implemented**. Preferences and their schema are complete; Status: **partially implemented**. Preferences and the complete background
model downloads, loading, inference, and idle unloading remain open. download workflow are implemented; loading, inference, and idle unloading
remain open.
1. Port the narrow GGUF loader, tokenizer, prompt renderer, and Metal session 1. Port the narrow GGUF loader, tokenizer, prompt renderer, and Metal session
API behind a small Rust `Engine` surface modeled on `ds4.h`: API behind a small Rust `Engine` surface modeled on `ds4.h`:
@@ -58,6 +60,48 @@ model downloads, loading, inference, and idle unloading remain open.
5. Move engine work off the UI thread and support cooperative cancellation at 5. Move engine work off the UI thread and support cooperative cancellation at
the safe session boundaries already defined by `ds4_session_sync`. the safe session boundaries already defined by `ds4_session_sync`.
### DS4 KV-cache port and shared session core
KV-cache behavior is part of the DS4 engine contract, not a new application
cache to redesign. Port the existing `ds4_session` and `ds4_kvstore` behavior
to Rust while preserving its semantics and on-disk compatibility:
- A live engine session owns one mutable inference timeline: exact rendered
tokens, logits, and the DS4-specific KV graph state. Callers provide the full
token prefix and the Rust port of `ds4_session_sync` must retain the longest
compatible prefix, evaluate only its suffix, or safely rewind, invalidate,
and rebuild exactly when DS4 does.
- Carry over DS4's safe cancellation/checkpoint boundaries, common-prefix
matching, canonical rewrite and replay, snapshots, payload staging,
save/load, strip-to-transcript behavior, and atomic replacement. The engine
continues to own the opaque KV payload format; UI, HTTP, and persistence code
must not reinterpret tensor state.
- Port `ds4_kvstore` policy rather than replacing it with a generic cache:
exact token and rendered-text prefix lookup, cold and continued aligned
checkpoints, model/quantization/context/payload-ABI compatibility checks,
tool-call replay metadata, disk-budget enforcement, hit tracking, and
eviction behavior.
- Use one process-wide Rust session registry and KV checkpoint store for both
local chat and the HTTP server. A remote request bound to an application
session resolves to the same logical DS4 session as the GUI and mutations are
serialized so two callers never write one live timeline concurrently.
Stateless HTTP requests still participate in the same compatible-prefix
checkpoint pool, so a prefix produced locally can be reused remotely and
vice versa.
- Store that shared checkpoint pool under Application Support in
`kv-cache/`, using the DS4 key/header/payload rules. Session rows hold only an
optional reference to their current checkpoint; the large opaque payload is
not copied into separate GUI and HTTP caches.
- Sharing does not merge unrelated conversations or expose one session's
transcript to another. It shares the DS4 implementation, checkpoint pool, and
explicitly bound logical session; each unrelated live conversation keeps its
own mutable session state.
- Engine unload must first leave every reusable live timeline in a valid
persisted checkpoint. Reload restores a compatible checkpoint immediately;
if model identity, quantization, context size, payload ABI, or rendered
history is incompatible, rebuild from the transcript and replace the stale
checkpoint only after the new state is valid.
### Lazy lifecycle ### Lazy lifecycle
- Start the application and the local HTTP endpoint with no model loaded. - Start the application and the local HTTP endpoint with no model loaded.
@@ -82,24 +126,45 @@ model downloads, loading, inference, and idle unloading remain open.
- Provide a Preferences panel from the sidebar and the standard - Provide a Preferences panel from the sidebar and the standard
macOS `Command-,` shortcut. macOS `Command-,` shortcut.
- Let the user choose the active model. Default to `deepseek-v4-flash`. - Let the user choose the active model. Default to `deepseek-v4-flash`.
- Let the user enable or disable DFlash for the selected model. Disable the - Let the user enable or disable DSpark for the selected model. Disable the
control for models that do not support DFlash. control for models that do not support DSpark.
- Let the user configure the inactivity timeout; default to 10 minutes. - Let the user configure the inactivity timeout; default to 10 minutes.
- Persist preferences in the application SQLite database. Changing preferences - Persist preferences in the application SQLite database. Changing preferences
never loads a model by itself. never loads a model by itself.
### Targeted downloads and storage ### Targeted downloads and storage
- The Preferences panel can download the selected model's main GGUF and the
DSpark support GGUF when enabled. A dedicated worker thread performs HTTP and
verification work so Preferences and the rest of the application stay
interactive.
- Use an in-process Rust HTTP client with Rustls rather than invoking a system
downloader. Stream response chunks directly into a `.part` file and use a
Rust SHA-256 implementation for verification.
- Report aggregate bytes present, total bytes, bytes missing, percentage,
smoothed transfer rate, current artifact/verification phase, and estimated
time remaining. Keep the detailed display in Preferences and a persistent
status bar with a Stop action while the transfer runs.
- Stop is non-destructive: close the active transfer and retain its `.part`
file. Starting the download again in the same process or after quitting and
relaunching must derive progress from that file and issue an HTTP range
request from its exact byte length. If the server ignores the range request,
safely restart that artifact from byte zero rather than appending corrupt
data.
- Promote a `.part` file only after its exact size and SHA-256 are verified. A
stopped verification keeps the complete partial file so the next run can
verify and promote it without downloading it again.
- Store managed model artifacts under - Store managed model artifacts under
`~/Library/Application Support/DS4Server.rfc1437.de/models/<model-id>/` and `~/Library/Application Support/DS4Server.rfc1437.de/models/<model-id>/` and
mmap/load them from there. Do not place managed models in the app bundle or a mmap/load them from there. Do not place managed models in the app bundle or a
project directory. project directory.
- Download only the selected model's main artifact. Download its DFlash - Download only the selected model's main artifact. Download its DSpark
artifact only when DFlash is enabled; never prefetch other catalog models or artifact only when DSpark is enabled; never prefetch other catalog models or
optional artifacts. optional artifacts.
- Verify size/checksum before making a download usable, resume partial - Remove checksum-invalid partial files without touching a valid model. Keep
downloads, and remove failed temporary files without touching a valid model. partial files after cancellation, application exit, network failure, or an
- When the selected model changes or DFlash is disabled, offer removal of the interrupted verification so they remain restart-resumable.
- When the selected model changes or DSpark is disabled, offer removal of the
now-unused artifact; do not delete large existing downloads without explicit now-unused artifact; do not delete large existing downloads without explicit
confirmation. confirmation.
@@ -128,41 +193,52 @@ Status: **open; not started**.
4. Port exact sampled tool-call replay and deterministic canonicalization from 4. Port exact sampled tool-call replay and deterministic canonicalization from
`ds4_server.c` so a client's normalized JSON does not destroy KV-prefix `ds4_server.c` so a client's normalized JSON does not destroy KV-prefix
reuse. reuse.
5. Bind API conversations to engine sessions and persist/restore KV payloads 5. Route API conversations through the shared session registry and KV store
using the engine-owned serialization format. Add resident batching only defined in Phase 1. Responses `conversation` bindings and any other
after single-session correctness is established. persistent request identity map to the same application session used by the
GUI; stateless requests use shared DS4 prefix lookup. Persist/restore only
through the engine-owned serialization format. Add resident batching only
after single-session and cross-surface correctness are established.
6. Display endpoint address, model, active requests, token rates, and errors in 6. Display endpoint address, model, active requests, token rates, and errors in
the GUI. the GUI.
Exit criterion: the upstream DwarfStar server tests pass against the app for Exit criterion: the upstream DwarfStar server tests pass against the app for
non-streaming and streaming Chat Completions and Responses calls, including a non-streaming and streaming Chat Completions and Responses calls, including a
multi-turn tool call. multi-turn tool call. A compatible prefix created by local chat must register a
remote cache hit, a remote checkpoint must be reusable by local chat, and a
GUI/HTTP race on one bound session must serialize without corrupting its token
or KV frontier.
## Phase 3 — durable agent sessions ## Phase 3 — durable agent sessions
Status: **partially open**. Project/session metadata rows exist; transcripts, Status: **partially open**. Project/session metadata rows exist; transcripts,
model binding, and KV payload persistence are not implemented. model binding, and KV payload persistence are not implemented.
Replace each metadata-only session with a directory: Extend each metadata-only session with transcript and shared-checkpoint
metadata:
```text ```text
Application Support/DS4Server.rfc1437.de/ Application Support/DS4Server.rfc1437.de/
data.sqlite3 data.sqlite3
models/<model-id>/ models/<model-id>/
main.gguf main.gguf
dflash.gguf # only when enabled dspark.gguf # only when enabled
sessions/<session-id>/ kv-cache/
kv.bin <ds4-cache-key>.kv
``` ```
- Add migrated message/transcript tables to SQLite. Keep only the engine-owned - Add migrated message/transcript tables and an optional current-checkpoint key
large KV payload in per-session files, written with atomic replacement. to SQLite. Keep large engine-owned KV payloads only in the process-wide Phase
1 store, written with atomic replacement; do not introduce separate
agent-only or HTTP-only cache formats.
- Record model identity, context size, rendered token history, title, - Record model identity, context size, rendered token history, title,
timestamps, and working directory. timestamps, and working directory.
- Restore compatible KV immediately. If KV is absent or incompatible, rebuild - Restore compatible KV immediately. If KV is absent or incompatible, rebuild
from the transcript and show prefill progress. from the transcript and show prefill progress.
- Add strip/archive behavior equivalent to the native agent: retain transcript - Add strip/archive behavior equivalent to the native agent: retain the
while removing the large KV payload. transcript and clear its checkpoint reference. Remove the cache file only
when it is not reusable by another live/session prefix; otherwise normal DS4
disk-budget eviction reclaims it.
- Version formats before the first release and add migrations only when a real - Version formats before the first release and add migrations only when a real
format change exists. format change exists.
@@ -176,7 +252,8 @@ visual placeholders with no message or agent runtime behind them.
1. Build the classic chat surface: transcript, reasoning disclosure, tool-call 1. Build the classic chat surface: transcript, reasoning disclosure, tool-call
cards, composer, queued input, stop button, prefill progress, and generation cards, composer, queued input, stop button, prefill progress, and generation
statistics. statistics. Local turns must call the same session registry and KV path used
by the HTTP endpoint.
2. Port the native agent turn loop from `ds4_agent.c`, including model-specific 2. Port the native agent turn loop from `ds4_agent.c`, including model-specific
system prompts and DeepSeek DSML/GLM tool-call parsing. system prompts and DeepSeek DSML/GLM tool-call parsing.
3. Implement the local tool set with the project directory as its boundary: 3. Implement the local tool set with the project directory as its boundary:
@@ -231,6 +308,7 @@ and the preferences shortcut have unit coverage. Model fixtures, integration
tests, and release packaging remain open. tests, and release packaging remain open.
- Keep unit coverage narrow: persistence round trips, format compatibility, - Keep unit coverage narrow: persistence round trips, format compatibility,
DS4 KV prefix/rewrite/store/load parity, cross-surface session binding,
request parsing, and tool-boundary checks. request parsing, and tool-boundary checks.
- Reuse `../ds4` fixtures for prompt rendering, sampling, server streaming, and - Reuse `../ds4` fixtures for prompt rendering, sampling, server streaming, and
KV correctness; run live Metal tests only when a supported GGUF is available. KV correctness; run live Metal tests only when a supported GGUF is available.
@@ -241,13 +319,11 @@ tests, and release packaging remain open.
## Recommended next milestones ## Recommended next milestones
1. Add the targeted download manager and model catalog, storing only selected 1. Implement the single-engine lifecycle and local generation path: lazy load,
artifacts under Application Support with resumable, verified downloads.
2. Implement the single-engine lifecycle and local generation path: lazy load,
shared concurrent acquisition, activity tracking, and idle unload. shared concurrent acquisition, activity tracking, and idle unload.
3. Put the OpenAI-compatible endpoint on that same engine lifecycle so local 2. Put the OpenAI-compatible endpoint on that same engine lifecycle so local
chats and HTTP requests cannot load duplicate engines. chats and HTTP requests cannot load duplicate engines.
4. Add transcript/KV persistence, then connect the existing conversation UI and 3. Add transcript/KV persistence, then connect the existing conversation UI and
finally port the agent tools. finally port the agent tools.
5. Add the opt-in Dev Brain tool after local file/search boundaries and agent 4. Add the opt-in Dev Brain tool after local file/search boundaries and agent
approvals are stable. approvals are stable.

View File

@@ -7,8 +7,13 @@ OpenAI-compatible localhost endpoint, and project-scoped agent chat in one app.
The current milestone provides a Codex-inspired project/session layout. A native The current milestone provides a Codex-inspired project/session layout. A native
macOS folder picker selects each workspace, then the app asks for its display macOS folder picker selects each workspace, then the app asks for its display
name. Projects, sessions, and model preferences are persisted through Diesel in name. Projects, sessions, and model preferences are persisted through Diesel in
SQLite. Open Preferences with `Command-,` to select the model, toggle DFlash, SQLite. Open Preferences with `Command-,` to select the model, toggle DSpark,
and configure the model idle timeout. configure the model idle timeout, and download the selected main GGUF with
its optional DSpark support. Rust-native background downloads show live byte
progress, speed, and ETA in Preferences and the app status bar. Stopping or
quitting keeps the partial file; the next Download/Resume action continues from
that exact byte after relaunch. Exact size and SHA-256 verification happen
before an artifact becomes usable.
```sh ```sh
cargo install cargo-packager --locked --version 0.11.8 cargo install cargo-packager --locked --version 0.11.8

View File

@@ -0,0 +1 @@
ALTER TABLE preferences RENAME COLUMN dspark_enabled TO dflash_enabled;

View File

@@ -0,0 +1 @@
ALTER TABLE preferences RENAME COLUMN dflash_enabled TO dspark_enabled;

View File

@@ -3,59 +3,24 @@ mod view;
pub(crate) use view::app_theme; pub(crate) use view::app_theme;
use crate::database::{AppPreferences, Database, ProjectWithSessions}; use crate::database::{AppPreferences, Database, ProjectWithSessions};
use crate::model::{self, DownloadOutcome, DownloadProgress, ModelChoice};
use iced::futures::{SinkExt, Stream};
use iced::{Subscription, Task, keyboard}; use iced::{Subscription, Task, keyboard};
use rfd::AsyncFileDialog; use rfd::AsyncFileDialog;
use std::fmt;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{self, TryRecvError};
use std::thread;
use std::time::{Duration, Instant};
const APP_ID: &str = "DS4Server.rfc1437.de"; const APP_ID: &str = "DS4Server.rfc1437.de";
const MODEL_CHOICES: [ModelChoice; 3] = [
ModelChoice::DeepSeekV4Flash,
ModelChoice::DeepSeekV4Pro,
ModelChoice::Glm52,
];
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(crate) enum ModelChoice {
#[default]
DeepSeekV4Flash,
DeepSeekV4Pro,
Glm52,
}
impl ModelChoice {
fn id(self) -> &'static str {
match self {
Self::DeepSeekV4Flash => "deepseek-v4-flash",
Self::DeepSeekV4Pro => "deepseek-v4-pro",
Self::Glm52 => "glm-5.2",
}
}
fn from_id(id: &str) -> Option<Self> {
MODEL_CHOICES.into_iter().find(|model| model.id() == id)
}
fn supports_dflash(self) -> bool {
self == Self::DeepSeekV4Flash
}
}
impl fmt::Display for ModelChoice {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::DeepSeekV4Flash => "DeepSeek V4 Flash",
Self::DeepSeekV4Pro => "DeepSeek V4 Pro",
Self::Glm52 => "GLM 5.2",
})
}
}
#[derive(Clone)] #[derive(Clone)]
struct PreferenceDraft { struct PreferenceDraft {
model: ModelChoice, model: ModelChoice,
dflash_enabled: bool, dspark_enabled: bool,
idle_timeout_minutes: String, idle_timeout_minutes: String,
} }
@@ -65,7 +30,7 @@ impl PreferenceDraft {
.ok_or_else(|| format!("Unsupported model: {}", preferences.selected_model))?; .ok_or_else(|| format!("Unsupported model: {}", preferences.selected_model))?;
Ok(Self { Ok(Self {
model, model,
dflash_enabled: model.supports_dflash() && preferences.dflash_enabled, dspark_enabled: model.supports_dspark() && preferences.dspark_enabled,
idle_timeout_minutes: preferences.idle_timeout_minutes.to_string(), idle_timeout_minutes: preferences.idle_timeout_minutes.to_string(),
}) })
} }
@@ -83,17 +48,42 @@ pub(crate) struct App {
choosing_folder: bool, choosing_folder: bool,
pending_project_path: Option<PathBuf>, pending_project_path: Option<PathBuf>,
project_name_input: String, project_name_input: String,
model_download: ModelDownload,
error: Option<String>, error: Option<String>,
} }
#[derive(Debug)]
pub(super) enum ModelDownload {
Idle,
Active(ActiveDownload),
Complete(ModelChoice, DownloadProgress),
Failed(ModelChoice, String, DownloadProgress),
}
#[derive(Debug)]
pub(super) struct ActiveDownload {
model: ModelChoice,
dspark_enabled: bool,
progress: DownloadProgress,
sampled_at: Instant,
sampled_bytes: u64,
bytes_per_second: f64,
cancel: Arc<AtomicBool>,
result: mpsc::Receiver<Result<DownloadOutcome, String>>,
stopping: bool,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub(crate) enum Message { pub(crate) enum Message {
OpenPreferences, OpenPreferences,
DismissPanel, DismissPanel,
PreferenceModelChanged(ModelChoice), PreferenceModelChanged(ModelChoice),
PreferenceDflashChanged(bool), PreferenceDsparkChanged(bool),
PreferenceTimeoutChanged(String), PreferenceTimeoutChanged(String),
SavePreferences, SavePreferences,
DownloadSelectedModel,
StopModelDownload,
DownloadProgressTick,
ChooseProjectFolder, ChooseProjectFolder,
ProjectFolderPicked(Option<PathBuf>), ProjectFolderPicked(Option<PathBuf>),
ProjectNameChanged(String), ProjectNameChanged(String),
@@ -128,6 +118,7 @@ impl App {
choosing_folder: false, choosing_folder: false,
pending_project_path: None, pending_project_path: None,
project_name_input: String::new(), project_name_input: String::new(),
model_download: ModelDownload::Idle,
error: None, error: None,
} }
} }
@@ -153,6 +144,7 @@ impl App {
choosing_folder: false, choosing_folder: false,
pending_project_path: None, pending_project_path: None,
project_name_input: String::new(), project_name_input: String::new(),
model_download: ModelDownload::Idle,
error: Some(format!("Could not open the project database: {error}")), error: Some(format!("Could not open the project database: {error}")),
} }
} }
@@ -172,14 +164,14 @@ impl App {
} }
Message::PreferenceModelChanged(model) => { Message::PreferenceModelChanged(model) => {
self.preference_draft.model = model; self.preference_draft.model = model;
if !model.supports_dflash() { if !model.supports_dspark() {
self.preference_draft.dflash_enabled = false; self.preference_draft.dspark_enabled = false;
} }
self.preference_error = None; self.preference_error = None;
} }
Message::PreferenceDflashChanged(enabled) => { Message::PreferenceDsparkChanged(enabled) => {
self.preference_draft.dflash_enabled = self.preference_draft.dspark_enabled =
self.preference_draft.model.supports_dflash() && enabled; self.preference_draft.model.supports_dspark() && enabled;
self.preference_error = None; self.preference_error = None;
} }
Message::PreferenceTimeoutChanged(value) => { Message::PreferenceTimeoutChanged(value) => {
@@ -187,6 +179,48 @@ impl App {
self.preference_error = None; self.preference_error = None;
} }
Message::SavePreferences => self.save_preferences(), Message::SavePreferences => self.save_preferences(),
Message::DownloadSelectedModel => {
if matches!(self.model_download, ModelDownload::Active(_)) {
return Task::none();
}
let model = self.preference_draft.model;
let dspark_enabled =
model.supports_dspark() && self.preference_draft.dspark_enabled;
let models_path = models_path();
let progress = model::download_progress(model, dspark_enabled, &models_path);
let cancel = Arc::new(AtomicBool::new(false));
let worker_cancel = Arc::clone(&cancel);
let (result_sender, result_receiver) = mpsc::channel();
if let Err(error) = thread::Builder::new()
.name("model-download".to_owned())
.spawn(move || {
let result =
model::download(model, dspark_enabled, &models_path, &worker_cancel);
let _ = result_sender.send(result);
})
{
self.error = Some(format!("Could not start model download: {error}"));
return Task::none();
}
self.model_download = ModelDownload::Active(ActiveDownload {
model,
dspark_enabled,
sampled_at: Instant::now(),
sampled_bytes: progress.downloaded,
progress,
bytes_per_second: 0.0,
cancel: Arc::clone(&cancel),
result: result_receiver,
stopping: false,
});
}
Message::StopModelDownload => {
if let ModelDownload::Active(download) = &mut self.model_download {
download.stopping = true;
download.cancel.store(true, Ordering::Relaxed);
}
}
Message::DownloadProgressTick => self.update_download_progress(),
Message::ChooseProjectFolder => { Message::ChooseProjectFolder => {
self.choosing_folder = true; self.choosing_folder = true;
return Task::perform( return Task::perform(
@@ -256,7 +290,15 @@ impl App {
} }
pub(crate) fn subscription(&self) -> Subscription<Message> { pub(crate) fn subscription(&self) -> Subscription<Message> {
keyboard::on_key_press(shortcut) let shortcuts = keyboard::on_key_press(shortcut);
if matches!(self.model_download, ModelDownload::Active(_)) {
Subscription::batch([
shortcuts,
Subscription::run(download_ticks).map(|_| Message::DownloadProgressTick),
])
} else {
shortcuts
}
} }
fn open_preferences(&mut self) { fn open_preferences(&mut self) {
@@ -289,11 +331,11 @@ impl App {
} }
let model = self.preference_draft.model; let model = self.preference_draft.model;
let dflash_enabled = model.supports_dflash() && self.preference_draft.dflash_enabled; let dspark_enabled = model.supports_dspark() && self.preference_draft.dspark_enabled;
let Some(database) = &mut self.database else { let Some(database) = &mut self.database else {
return; return;
}; };
match database.update_preferences(model.id(), dflash_enabled, idle_timeout_minutes) { match database.update_preferences(model.id(), dspark_enabled, idle_timeout_minutes) {
Ok(preferences) => { Ok(preferences) => {
self.preferences = preferences; self.preferences = preferences;
self.preference_draft = PreferenceDraft::from_saved(&self.preferences) self.preference_draft = PreferenceDraft::from_saved(&self.preferences)
@@ -395,6 +437,73 @@ impl App {
} }
} }
} }
fn update_download_progress(&mut self) {
let ModelDownload::Active(download) = &mut self.model_download else {
return;
};
let now = Instant::now();
let progress =
model::download_progress(download.model, download.dspark_enabled, &models_path());
let elapsed = now.duration_since(download.sampled_at).as_secs_f64();
let transferred = progress.downloaded.saturating_sub(download.sampled_bytes);
if transferred > 0 && elapsed > 0.0 {
let current = transferred as f64 / elapsed;
download.bytes_per_second = if download.bytes_per_second == 0.0 {
current
} else {
download.bytes_per_second * 0.75 + current * 0.25
};
}
download.progress = progress;
download.sampled_at = now;
download.sampled_bytes = download.progress.downloaded;
let result = match download.result.try_recv() {
Ok(result) => Some(result),
Err(TryRecvError::Empty) => None,
Err(TryRecvError::Disconnected) => {
Some(Err("Download worker stopped unexpectedly.".into()))
}
};
let model = download.model;
let progress = download.progress.clone();
if let Some(result) = result {
match result {
Ok(DownloadOutcome::Complete) => {
self.model_download = ModelDownload::Complete(model, progress);
self.error = None;
}
Ok(DownloadOutcome::Stopped) => {
self.model_download = ModelDownload::Idle;
self.error = None;
}
Err(error) => {
self.error = Some(error.clone());
self.model_download = ModelDownload::Failed(model, error, progress);
}
}
}
}
}
impl Drop for App {
fn drop(&mut self) {
if let ModelDownload::Active(download) = &self.model_download {
download.cancel.store(true, Ordering::Relaxed);
}
}
}
fn download_ticks() -> impl Stream<Item = ()> {
iced::stream::channel(1, |mut output| async move {
loop {
std::thread::sleep(Duration::from_secs(1));
if output.send(()).await.is_err() {
break;
}
}
})
} }
fn shortcut(key: keyboard::Key, modifiers: keyboard::Modifiers) -> Option<Message> { fn shortcut(key: keyboard::Key, modifiers: keyboard::Modifiers) -> Option<Message> {
@@ -414,19 +523,23 @@ fn application_support_path() -> PathBuf {
.join(APP_ID) .join(APP_ID)
} }
fn models_path() -> PathBuf {
application_support_path().join("models")
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
fn preferences_shortcut_and_dflash_support_are_explicit() { fn preferences_shortcut_and_dspark_support_are_explicit() {
let message = shortcut( let message = shortcut(
keyboard::Key::Character(",".into()), keyboard::Key::Character(",".into()),
keyboard::Modifiers::COMMAND, keyboard::Modifiers::COMMAND,
); );
assert!(matches!(message, Some(Message::OpenPreferences))); assert!(matches!(message, Some(Message::OpenPreferences)));
assert!(ModelChoice::DeepSeekV4Flash.supports_dflash()); assert!(ModelChoice::DeepSeekV4Flash.supports_dspark());
assert!(!ModelChoice::DeepSeekV4Pro.supports_dflash()); assert!(!ModelChoice::DeepSeekV4Pro.supports_dspark());
assert!(!ModelChoice::Glm52.supports_dflash()); assert!(!ModelChoice::Glm52.supports_dspark());
} }
} }

View File

@@ -1,9 +1,10 @@
use super::{App, MODEL_CHOICES, Message, ModelChoice}; use super::{ActiveDownload, App, Message, ModelDownload, models_path};
use crate::database::{ProjectWithSessions, Session}; use crate::database::{ProjectWithSessions, Session};
use crate::model::{self, DownloadPhase, DownloadProgress, MODEL_CHOICES, ModelChoice};
use iced::theme::{Palette, palette}; use iced::theme::{Palette, palette};
use iced::widget::{ use iced::widget::{
Space, Svg, button, checkbox, column, container, opaque, pick_list, row, scrollable, stack, Space, Svg, button, checkbox, column, container, opaque, pick_list, progress_bar, row,
svg, text, text_input, scrollable, stack, svg, text, text_input,
}; };
use iced::{Alignment, Color, Element, Length, Theme}; use iced::{Alignment, Color, Element, Length, Theme};
use std::path::Path; use std::path::Path;
@@ -27,6 +28,9 @@ impl App {
.width(Length::Fill) .width(Length::Fill)
.height(Length::Fill) .height(Length::Fill)
]; ];
if let ModelDownload::Active(download) = &self.model_download {
shell = shell.push(download_status_bar(download));
}
if let Some(error) = &self.error { if let Some(error) = &self.error {
shell = shell.push( shell = shell.push(
container(text(error).style(iced::widget::text::danger)) container(text(error).style(iced::widget::text::danger))
@@ -277,16 +281,43 @@ impl App {
} }
fn preferences_panel(&self) -> Element<'_, Message> { fn preferences_panel(&self) -> Element<'_, Message> {
let dflash_toggle: Option<fn(bool) -> Message> = self let dspark_toggle: Option<fn(bool) -> Message> = self
.preference_draft .preference_draft
.model .model
.supports_dflash() .supports_dspark()
.then_some(Message::PreferenceDflashChanged); .then_some(Message::PreferenceDsparkChanged);
let dflash = checkbox( let dspark = checkbox(
"Enable DFlash for this model", "Enable DSpark for this model",
self.preference_draft.dflash_enabled, self.preference_draft.dspark_enabled,
) )
.on_toggle_maybe(dflash_toggle); .on_toggle_maybe(dspark_toggle);
let model = self.preference_draft.model;
let dspark_enabled = model.supports_dspark() && self.preference_draft.dspark_enabled;
let selected_progress = model::download_progress(model, dspark_enabled, &models_path());
let installed = selected_progress.phase == DownloadPhase::Complete;
let download = if let ModelDownload::Active(download) = &self.model_download {
if download.stopping {
button("Stopping…")
} else {
button("Stop download")
.on_press(Message::StopModelDownload)
.style(button::danger)
}
} else if installed {
button("Downloaded")
} else {
let action = if selected_progress.downloaded > 0 {
"Resume"
} else {
"Download"
};
button(text(format!(
"{action} {} ({})",
model,
format_bytes(selected_progress.total)
)))
.on_press(Message::DownloadSelectedModel)
};
let mut content = column![ let mut content = column![
row![ row![
@@ -305,13 +336,15 @@ impl App {
Message::PreferenceModelChanged, Message::PreferenceModelChanged,
) )
.width(Length::Fill), .width(Length::Fill),
dflash, dspark,
text(if self.preference_draft.model.supports_dflash() { text(if self.preference_draft.model.supports_dspark() {
"DFlash is downloaded and used only when enabled." "DSpark support is downloaded and used only when enabled."
} else { } else {
"DFlash is not available for the selected model." "DSpark is not available for the selected model."
}) })
.size(12), .size(12),
download.style(button::secondary),
model_download_status(&self.model_download, &selected_progress),
Space::with_height(8), Space::with_height(8),
text("INACTIVITY").size(11), text("INACTIVITY").size(11),
row![ row![
@@ -408,6 +441,152 @@ impl App {
} }
} }
fn download_status_bar(download: &ActiveDownload) -> Element<'_, Message> {
let progress = &download.progress;
let percent = progress.fraction() * 100.0;
let transfer = if download.bytes_per_second > 0.0 && progress.remaining() > 0 {
format!(
"{}/s • about {} remaining",
format_bytes(download.bytes_per_second as u64),
format_duration(progress.remaining() as f64 / download.bytes_per_second),
)
} else {
"Calculating time remaining…".to_owned()
};
let stop = if download.stopping {
button("Stopping…")
} else {
button("Stop")
.on_press(Message::StopModelDownload)
.style(button::danger)
};
container(
row![
text(phase_text(progress.phase)).size(12),
progress_bar(0.0..=1.0, progress.fraction())
.width(180)
.height(7),
text(format!(
"{} of {} ({percent:.1}%)",
format_bytes(progress.downloaded),
format_bytes(progress.total),
))
.size(12),
text(transfer).size(12),
Space::with_width(Length::Fill),
stop,
]
.spacing(12)
.align_y(Alignment::Center),
)
.width(Length::Fill)
.padding([7, 14])
.style(sidebar_style)
.into()
}
fn model_download_status<'a>(
download: &ModelDownload,
selected: &DownloadProgress,
) -> Element<'a, Message> {
let (progress, heading, speed, failed) = match download {
ModelDownload::Idle => (
selected,
if selected.downloaded > 0 {
"Paused — the next download will resume from this point.".to_owned()
} else {
phase_text(selected.phase)
},
0.0,
false,
),
ModelDownload::Active(active) => (
&active.progress,
if active.stopping {
format!("Stopping {}", active.model)
} else {
phase_text(active.progress.phase)
},
active.bytes_per_second,
false,
),
ModelDownload::Complete(model, progress) => (
progress,
format!("{model} is downloaded and verified."),
0.0,
false,
),
ModelDownload::Failed(model, error, progress) => (
progress,
format!("{model} download failed: {error}"),
0.0,
true,
),
};
let percent = progress.fraction() * 100.0;
let mut status = column![
if failed {
text(heading).size(12).style(iced::widget::text::danger)
} else {
text(heading).size(12)
},
progress_bar(0.0..=1.0, progress.fraction()).height(8),
text(format!(
"{} of {} ({percent:.1}%) • {} remaining",
format_bytes(progress.downloaded),
format_bytes(progress.total),
format_bytes(progress.remaining()),
))
.size(12),
]
.spacing(6);
if speed > 0.0 && progress.remaining() > 0 {
status = status.push(
text(format!(
"{}/s • about {} remaining",
format_bytes(speed as u64),
format_duration(progress.remaining() as f64 / speed),
))
.size(12),
);
}
status.into()
}
fn phase_text(phase: DownloadPhase) -> String {
match phase {
DownloadPhase::Pending(artifact) => format!("Ready to download {artifact}."),
DownloadPhase::Downloading(artifact) => format!("Downloading {artifact}"),
DownloadPhase::Verifying(artifact) => format!("Verifying {artifact}"),
DownloadPhase::Complete => "Download complete.".to_owned(),
}
}
fn format_bytes(bytes: u64) -> String {
const GB: f64 = 1_000_000_000.0;
const MB: f64 = 1_000_000.0;
if bytes >= 1_000_000_000 {
format!("{:.1} GB", bytes as f64 / GB)
} else {
format!("{:.1} MB", bytes as f64 / MB)
}
}
fn format_duration(seconds: f64) -> String {
let seconds = seconds.max(0.0).round() as u64;
let hours = seconds / 3600;
let minutes = seconds % 3600 / 60;
if hours > 0 {
format!("{hours}h {minutes}m")
} else if minutes > 0 {
format!("{minutes}m {}s", seconds % 60)
} else {
format!("{seconds}s")
}
}
pub(crate) fn app_theme() -> Theme { pub(crate) fn app_theme() -> Theme {
let palette = Palette { let palette = Palette {
background: Color::from_rgb8(29, 29, 31), background: Color::from_rgb8(29, 29, 31),
@@ -451,4 +630,11 @@ mod tests {
assert_eq!(palette.background.weak.color, Color::from_rgb8(38, 38, 40)); assert_eq!(palette.background.weak.color, Color::from_rgb8(38, 38, 40));
assert_eq!(palette.secondary.base.color, Color::from_rgb8(47, 47, 50)); assert_eq!(palette.secondary.base.color, Color::from_rgb8(47, 47, 50));
} }
#[test]
fn download_measurements_are_readable() {
assert_eq!(format_bytes(1_500_000_000), "1.5 GB");
assert_eq!(format_duration(7_384.0), "2h 3m");
assert_eq!(format_duration(125.0), "2m 5s");
}
} }

View File

@@ -14,7 +14,7 @@ pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
pub struct AppPreferences { pub struct AppPreferences {
pub id: i32, pub id: i32,
pub selected_model: String, pub selected_model: String,
pub dflash_enabled: bool, pub dspark_enabled: bool,
pub idle_timeout_minutes: i32, pub idle_timeout_minutes: i32,
} }
@@ -23,7 +23,7 @@ impl Default for AppPreferences {
Self { Self {
id: 1, id: 1,
selected_model: "deepseek-v4-flash".into(), selected_model: "deepseek-v4-flash".into(),
dflash_enabled: false, dspark_enabled: false,
idle_timeout_minutes: 10, idle_timeout_minutes: 10,
} }
} }
@@ -33,7 +33,7 @@ impl Default for AppPreferences {
#[diesel(table_name = preferences)] #[diesel(table_name = preferences)]
struct PreferenceChanges<'a> { struct PreferenceChanges<'a> {
selected_model: &'a str, selected_model: &'a str,
dflash_enabled: bool, dspark_enabled: bool,
idle_timeout_minutes: i32, idle_timeout_minutes: i32,
} }
@@ -135,13 +135,13 @@ impl Database {
pub fn update_preferences( pub fn update_preferences(
&mut self, &mut self,
selected_model: &str, selected_model: &str,
dflash_enabled: bool, dspark_enabled: bool,
idle_timeout_minutes: i32, idle_timeout_minutes: i32,
) -> Result<AppPreferences, String> { ) -> Result<AppPreferences, String> {
diesel::update(preferences::table.find(1)) diesel::update(preferences::table.find(1))
.set(PreferenceChanges { .set(PreferenceChanges {
selected_model, selected_model,
dflash_enabled, dspark_enabled,
idle_timeout_minutes, idle_timeout_minutes,
}) })
.returning(AppPreferences::as_returning()) .returning(AppPreferences::as_returning())
@@ -200,7 +200,7 @@ mod tests {
let preferences = database.load_preferences().unwrap(); let preferences = database.load_preferences().unwrap();
assert_eq!(preferences.selected_model, "deepseek-v4-flash"); assert_eq!(preferences.selected_model, "deepseek-v4-flash");
assert!(!preferences.dflash_enabled); assert!(!preferences.dspark_enabled);
assert_eq!(preferences.idle_timeout_minutes, 10); assert_eq!(preferences.idle_timeout_minutes, 10);
assert!(database.update_preferences("glm-5.2", true, 30).is_err()); assert!(database.update_preferences("glm-5.2", true, 30).is_err());
assert!( assert!(

View File

@@ -1,5 +1,6 @@
mod app; mod app;
mod database; mod database;
mod model;
mod schema; mod schema;
use app::{App, app_theme}; use app::{App, app_theme};

567
src/model.rs Normal file
View File

@@ -0,0 +1,567 @@
use std::fmt;
use std::fs::{self, File, OpenOptions};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use sha2::{Digest, Sha256};
pub(crate) const MODEL_CHOICES: [ModelChoice; 3] = [
ModelChoice::DeepSeekV4Flash,
ModelChoice::DeepSeekV4Pro,
ModelChoice::Glm52,
];
const DEEPSEEK_REPOSITORY: &str = "antirez/deepseek-v4-gguf";
const GLM_REPOSITORY: &str = "antirez/glm-5.2-gguf";
const FLASH: Artifact = Artifact {
label: "DeepSeek V4 Flash model",
file_name: "DeepSeek-V4-Flash-IQ2XXS-w2Q2K-AProjQ8-SExpQ8-OutQ8-chat-v2-imatrix.gguf",
repository: DEEPSEEK_REPOSITORY,
size: 86_720_111_488,
sha256: "efc7ed607ff27076e3e501fc3fefefa33c0ed8cf1eff483a2b7fdc0c2e616668",
};
const FLASH_DSPARK: Artifact = Artifact {
label: "DSpark support",
file_name: "DeepSeek-V4-Flash-DSpark-support.gguf",
repository: DEEPSEEK_REPOSITORY,
size: 5_989_114_272,
sha256: "8b3adf5942bec22ae2ea867cd7079cf13530ba83ffcffaf00f5de48664a1a34e",
};
const PRO: Artifact = Artifact {
label: "DeepSeek V4 Pro model",
file_name: "DeepSeek-V4-Pro-IQ2XXS-w2Q2K-AProjQ8-SExpQ8-OutQ8-Instruct-imatrix.gguf",
repository: DEEPSEEK_REPOSITORY,
size: 464_627_334_560,
sha256: "a0314d9c0e16122cd60071079124a2d17185d317c55a8f95ecb3ed3506278a96",
};
const GLM: Artifact = Artifact {
label: "GLM 5.2 model",
file_name: "GLM-5.2-UD-IQ2_XXS_RoutedIQ2XXS_blk78Q2K.gguf",
repository: GLM_REPOSITORY,
size: 211_075_856_448,
sha256: "a49de64c5020432bdae23de36a423a9660a5621bc0db8d12b66bd8814b07fea0",
};
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(crate) enum ModelChoice {
#[default]
DeepSeekV4Flash,
DeepSeekV4Pro,
Glm52,
}
impl ModelChoice {
pub(crate) fn id(self) -> &'static str {
match self {
Self::DeepSeekV4Flash => "deepseek-v4-flash",
Self::DeepSeekV4Pro => "deepseek-v4-pro",
Self::Glm52 => "glm-5.2",
}
}
pub(crate) fn from_id(id: &str) -> Option<Self> {
MODEL_CHOICES.into_iter().find(|model| model.id() == id)
}
pub(crate) fn supports_dspark(self) -> bool {
self == Self::DeepSeekV4Flash
}
fn main_artifact(self) -> &'static Artifact {
match self {
Self::DeepSeekV4Flash => &FLASH,
Self::DeepSeekV4Pro => &PRO,
Self::Glm52 => &GLM,
}
}
fn artifacts(self, dspark_enabled: bool) -> impl Iterator<Item = &'static Artifact> {
[
Some(self.main_artifact()),
(self.supports_dspark() && dspark_enabled).then_some(&FLASH_DSPARK),
]
.into_iter()
.flatten()
}
pub(crate) fn download_size(self, dspark_enabled: bool) -> u64 {
self.artifacts(dspark_enabled)
.map(|artifact| artifact.size)
.sum()
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum DownloadPhase {
Pending(&'static str),
Downloading(&'static str),
Verifying(&'static str),
Complete,
}
#[derive(Clone, Debug)]
pub(crate) struct DownloadProgress {
pub(crate) downloaded: u64,
pub(crate) total: u64,
pub(crate) phase: DownloadPhase,
}
impl DownloadProgress {
pub(crate) fn remaining(&self) -> u64 {
self.total.saturating_sub(self.downloaded)
}
pub(crate) fn fraction(&self) -> f32 {
if self.total == 0 {
0.0
} else {
self.downloaded as f32 / self.total as f32
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum DownloadOutcome {
Complete,
Stopped,
}
impl fmt::Display for ModelChoice {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::DeepSeekV4Flash => "DeepSeek V4 Flash",
Self::DeepSeekV4Pro => "DeepSeek V4 Pro",
Self::Glm52 => "GLM 5.2",
})
}
}
struct Artifact {
label: &'static str,
file_name: &'static str,
repository: &'static str,
size: u64,
sha256: &'static str,
}
impl Artifact {
fn path(&self, model: ModelChoice, models_path: &Path) -> PathBuf {
models_path.join(model.id()).join(self.file_name)
}
fn url(&self) -> String {
format!(
"https://huggingface.co/{}/resolve/main/{}",
self.repository, self.file_name
)
}
fn verification_path(&self, model: ModelChoice, models_path: &Path) -> PathBuf {
self.path(model, models_path).with_extension("gguf.sha256")
}
fn partial_path(&self, model: ModelChoice, models_path: &Path) -> PathBuf {
self.path(model, models_path).with_extension("gguf.part")
}
fn is_installed(&self, model: ModelChoice, models_path: &Path) -> bool {
self.path(model, models_path)
.metadata()
.is_ok_and(|metadata| metadata.len() == self.size)
&& fs::read_to_string(self.verification_path(model, models_path))
.is_ok_and(|sha256| sha256.trim() == self.sha256)
}
fn downloaded_bytes(&self, model: ModelChoice, models_path: &Path) -> u64 {
if self.is_installed(model, models_path) {
return self.size;
}
self.path(model, models_path)
.metadata()
.or_else(|_| self.partial_path(model, models_path).metadata())
.map_or(0, |metadata| metadata.len().min(self.size))
}
}
pub(crate) fn download_progress(
model: ModelChoice,
dspark_enabled: bool,
models_path: &Path,
) -> DownloadProgress {
let mut downloaded = 0;
let mut phase = DownloadPhase::Complete;
for artifact in model.artifacts(dspark_enabled) {
let bytes = artifact.downloaded_bytes(model, models_path);
downloaded += bytes;
if phase == DownloadPhase::Complete && !artifact.is_installed(model, models_path) {
phase = if bytes >= artifact.size {
DownloadPhase::Verifying(artifact.label)
} else if bytes > 0 {
DownloadPhase::Downloading(artifact.label)
} else {
DownloadPhase::Pending(artifact.label)
};
}
}
DownloadProgress {
downloaded,
total: model.download_size(dspark_enabled),
phase,
}
}
pub(crate) fn download(
model: ModelChoice,
dspark_enabled: bool,
models_path: &Path,
cancel: &AtomicBool,
) -> Result<DownloadOutcome, String> {
for artifact in model.artifacts(dspark_enabled) {
if download_artifact_with_cancel(model, artifact, models_path, cancel)?
== DownloadOutcome::Stopped
{
return Ok(DownloadOutcome::Stopped);
}
}
Ok(DownloadOutcome::Complete)
}
#[cfg(test)]
fn download_artifact(
model: ModelChoice,
artifact: &Artifact,
models_path: &Path,
) -> Result<DownloadOutcome, String> {
download_artifact_with_cancel(model, artifact, models_path, &AtomicBool::new(false))
}
fn download_artifact_with_cancel(
model: ModelChoice,
artifact: &Artifact,
models_path: &Path,
cancel: &AtomicBool,
) -> Result<DownloadOutcome, String> {
if cancel.load(Ordering::Relaxed) {
return Ok(DownloadOutcome::Stopped);
}
let destination = artifact.path(model, models_path);
if artifact.is_installed(model, models_path) {
return Ok(DownloadOutcome::Complete);
}
if destination.exists() {
if verify(&destination, artifact, cancel)? == DownloadOutcome::Stopped {
return Ok(DownloadOutcome::Stopped);
}
mark_verified(model, artifact, models_path)?;
return Ok(DownloadOutcome::Complete);
}
let directory = destination
.parent()
.ok_or_else(|| "model artifact path has no parent directory".to_owned())?;
fs::create_dir_all(directory).map_err(|error| error.to_string())?;
let partial = artifact.partial_path(model, models_path);
let partial_size = partial.metadata().map_or(0, |metadata| metadata.len());
if partial_size > artifact.size {
File::create(&partial).map_err(|error| error.to_string())?;
}
if partial.metadata().map_or(0, |metadata| metadata.len()) != artifact.size
&& download_to_partial(artifact, &partial, cancel)? == DownloadOutcome::Stopped
{
return Ok(DownloadOutcome::Stopped);
}
if cancel.load(Ordering::Relaxed) {
return Ok(DownloadOutcome::Stopped);
}
match verify(&partial, artifact, cancel) {
Ok(DownloadOutcome::Stopped) => return Ok(DownloadOutcome::Stopped),
Ok(DownloadOutcome::Complete) => {}
Err(error) => {
fs::remove_file(&partial).map_err(|remove_error| {
format!("{error}; could not remove partial file: {remove_error}")
})?;
return Err(error);
}
}
fs::rename(partial, destination).map_err(|error| error.to_string())?;
mark_verified(model, artifact, models_path)?;
Ok(DownloadOutcome::Complete)
}
fn mark_verified(
model: ModelChoice,
artifact: &Artifact,
models_path: &Path,
) -> Result<(), String> {
fs::write(
artifact.verification_path(model, models_path),
artifact.sha256,
)
.map_err(|error| error.to_string())
}
fn verify(
path: &Path,
artifact: &Artifact,
cancel: &AtomicBool,
) -> Result<DownloadOutcome, String> {
let size = path.metadata().map_err(|error| error.to_string())?.len();
if size != artifact.size {
return Err(format!(
"{} has size {size}, expected {}",
path.display(),
artifact.size
));
}
let mut file = File::open(path).map_err(|error| error.to_string())?;
let mut hasher = Sha256::new();
let mut buffer = vec![0; 1024 * 1024];
loop {
if cancel.load(Ordering::Relaxed) {
return Ok(DownloadOutcome::Stopped);
}
let count = file.read(&mut buffer).map_err(|error| error.to_string())?;
if count == 0 {
break;
}
hasher.update(&buffer[..count]);
}
let actual = hex(&hasher.finalize());
if actual != artifact.sha256 {
return Err(format!(
"Checksum verification failed for {}",
path.display()
));
}
Ok(DownloadOutcome::Complete)
}
fn hex(bytes: &[u8]) -> String {
const DIGITS: &[u8; 16] = b"0123456789abcdef";
let mut result = String::with_capacity(bytes.len() * 2);
for byte in bytes {
result.push(DIGITS[(byte >> 4) as usize] as char);
result.push(DIGITS[(byte & 0x0f) as usize] as char);
}
result
}
fn download_to_partial(
artifact: &Artifact,
partial: &Path,
cancel: &AtomicBool,
) -> Result<DownloadOutcome, String> {
download_url_to_partial(&artifact.url(), partial, cancel)
}
fn download_url_to_partial(
url: &str,
partial: &Path,
cancel: &AtomicBool,
) -> Result<DownloadOutcome, String> {
let offset = partial.metadata().map_or(0, |metadata| metadata.len());
let agent: ureq::Agent = ureq::Agent::config_builder()
.https_only(url.starts_with("https://"))
.build()
.into();
let mut request = agent.get(url);
if offset > 0 {
request = request.header("Range", format!("bytes={offset}-"));
}
let mut response = request
.call()
.map_err(|error| format!("Model download failed: {error}"))?;
let status = response.status().as_u16();
let append = offset > 0 && status == 206;
if offset > 0 && status != 200 && status != 206 {
return Err(format!(
"Model server returned HTTP {status} while resuming at byte {offset}"
));
}
if append {
validate_content_range(&response, offset)?;
}
let mut output = OpenOptions::new()
.create(true)
.write(true)
.append(append)
.truncate(!append)
.open(partial)
.map_err(|error| error.to_string())?;
let mut body = response.body_mut().as_reader();
let mut buffer = vec![0; 1024 * 1024];
loop {
if cancel.load(Ordering::Relaxed) {
return Ok(DownloadOutcome::Stopped);
}
let count = body
.read(&mut buffer)
.map_err(|error| format!("Model download failed: {error}"))?;
if count == 0 {
break;
}
output
.write_all(&buffer[..count])
.map_err(|error| error.to_string())?;
}
Ok(DownloadOutcome::Complete)
}
fn validate_content_range(
response: &ureq::http::Response<ureq::Body>,
offset: u64,
) -> Result<(), String> {
let expected = format!("bytes {offset}-");
let content_range = response
.headers()
.get("content-range")
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
if content_range.starts_with(&expected) {
Ok(())
} else {
Err(format!(
"Model server returned an invalid Content-Range while resuming at byte {offset}"
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::net::TcpListener;
use std::thread;
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn catalog_and_checksum_verification_are_explicit() {
assert_eq!(ModelChoice::from_id("glm-5.2"), Some(ModelChoice::Glm52));
assert!(ModelChoice::from_id("unknown").is_none());
assert_eq!(
ModelChoice::DeepSeekV4Flash.main_artifact().size,
86_720_111_488
);
assert_eq!(ModelChoice::Glm52.main_artifact().size, 211_075_856_448);
assert_eq!(ModelChoice::DeepSeekV4Flash.artifacts(true).count(), 2);
assert_eq!(ModelChoice::Glm52.artifacts(true).count(), 1);
let id = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let models_path = std::env::temp_dir().join(format!("ds4-server-models-{id}"));
let empty = Artifact {
label: "empty",
file_name: "empty",
repository: "",
size: 0,
sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
};
let partial = empty.partial_path(ModelChoice::DeepSeekV4Flash, &models_path);
fs::create_dir_all(partial.parent().unwrap()).unwrap();
fs::write(&partial, []).unwrap();
download_artifact(ModelChoice::DeepSeekV4Flash, &empty, &models_path).unwrap();
assert!(empty.is_installed(ModelChoice::DeepSeekV4Flash, &models_path));
assert!(!partial.exists());
assert_eq!(
fs::read_to_string(empty.verification_path(ModelChoice::DeepSeekV4Flash, &models_path))
.unwrap(),
empty.sha256
);
fs::remove_dir_all(models_path).unwrap();
}
#[test]
fn restart_resumes_at_the_existing_partial_byte() {
let content = b"restart-resume works";
let offset = 8;
let directory = std::env::temp_dir().join(format!(
"ds4-server-resume-{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&directory).unwrap();
let partial = directory.join("model.gguf.part");
fs::write(&partial, &content[..offset]).unwrap();
let listener = match TcpListener::bind("127.0.0.1:0") {
Ok(listener) => listener,
Err(error) if error.kind() == std::io::ErrorKind::PermissionDenied => {
fs::remove_dir_all(directory).unwrap();
return;
}
Err(error) => panic!("could not start test server: {error}"),
};
let address = listener.local_addr().unwrap();
let server = thread::spawn(move || {
let (mut connection, _) = listener.accept().unwrap();
let mut request = [0; 2048];
let count = connection.read(&mut request).unwrap();
let request = String::from_utf8_lossy(&request[..count]).to_ascii_lowercase();
assert!(request.contains("range: bytes=8-"));
let remaining = &content[offset..];
write!(
connection,
"HTTP/1.1 206 Partial Content\r\nContent-Length: {}\r\nContent-Range: bytes {offset}-{}/{}\r\nConnection: close\r\n\r\n",
remaining.len(),
content.len() - 1,
content.len(),
)
.unwrap();
connection.write_all(remaining).unwrap();
});
let outcome = download_url_to_partial(
&format!("http://{address}/model.gguf"),
&partial,
&AtomicBool::new(false),
)
.unwrap();
server.join().unwrap();
assert_eq!(outcome, DownloadOutcome::Complete);
assert_eq!(fs::read(&partial).unwrap(), content);
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn cancellation_keeps_the_partial_file_for_the_next_run() {
let directory = std::env::temp_dir().join(format!(
"ds4-server-cancel-{}",
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let artifact = Artifact {
label: "test model",
file_name: "model.gguf",
repository: "unused",
size: 10,
sha256: "unused",
};
let partial = artifact.partial_path(ModelChoice::DeepSeekV4Flash, &directory);
fs::create_dir_all(partial.parent().unwrap()).unwrap();
fs::write(&partial, b"part").unwrap();
let cancel = AtomicBool::new(true);
assert_eq!(
download_artifact_with_cancel(
ModelChoice::DeepSeekV4Flash,
&artifact,
&directory,
&cancel,
)
.unwrap(),
DownloadOutcome::Stopped
);
assert_eq!(fs::read(&partial).unwrap(), b"part");
fs::remove_dir_all(directory).unwrap();
}
}

View File

@@ -10,7 +10,7 @@ diesel::table! {
preferences (id) { preferences (id) {
id -> Integer, id -> Integer,
selected_model -> Text, selected_model -> Text,
dflash_enabled -> Bool, dspark_enabled -> Bool,
idle_timeout_minutes -> Integer, idle_timeout_minutes -> Integer,
} }
} }