add preferences and macOS packaging
This commit is contained in:
@@ -11,7 +11,7 @@ Run before every commit:
|
|||||||
```sh
|
```sh
|
||||||
cargo fmt --all -- --check
|
cargo fmt --all -- --check
|
||||||
cargo clippy --all-targets --all-features -- -D warnings
|
cargo clippy --all-targets --all-features -- -D warnings
|
||||||
cargo build --all-targets --all-features
|
make bundle
|
||||||
cargo test --all-features
|
cargo test --all-features
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
11
Cargo.toml
11
Cargo.toml
@@ -11,7 +11,12 @@ 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"
|
||||||
|
|
||||||
[package.metadata.bundle]
|
[package.metadata.packager]
|
||||||
name = "DS4Server"
|
product-name = "DS4Server"
|
||||||
identifier = "DS4Server.rfc1437.de"
|
identifier = "DS4Server.rfc1437.de"
|
||||||
osx_minimum_system_version = "13.0"
|
description = "A native macOS coding-agent GUI for DwarfStar"
|
||||||
|
binaries = [{ path = "ds4-server", main = true }]
|
||||||
|
icons = ["assets/DS4Server.icns", "assets/app-icon.png"]
|
||||||
|
|
||||||
|
[package.metadata.packager.macos]
|
||||||
|
minimum-system-version = "13.0"
|
||||||
|
|||||||
6
Makefile
Normal file
6
Makefile
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.PHONY: bundle
|
||||||
|
|
||||||
|
bundle:
|
||||||
|
cargo build --release --all-targets --all-features
|
||||||
|
cargo packager --release --formats app
|
||||||
|
codesign --force --deep --sign - --timestamp=none target/release/DS4Server.app
|
||||||
120
PLAN.md
120
PLAN.md
@@ -8,11 +8,30 @@ reference: `ds4.c`/`ds4.h` define model and session behavior,
|
|||||||
`ds4_server.c` defines the compatible API, and `ds4_agent.c` defines agent
|
`ds4_server.c` defines the compatible API, and `ds4_agent.c` defines agent
|
||||||
sessions, tools, and turn orchestration.
|
sessions, tools, and turn orchestration.
|
||||||
|
|
||||||
## Phase 0 — project and session shell (implemented)
|
## Current status
|
||||||
|
|
||||||
|
| Area | Status | Available now | Still open |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 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 |
|
||||||
|
| 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 |
|
||||||
|
| 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 |
|
||||||
|
| Release | Not started | Development binary builds and tests | `.app` packaging, signing, entitlements, and notarization |
|
||||||
|
|
||||||
|
The application currently manages durable project/session metadata and model
|
||||||
|
preferences. It does not yet download or load a model, serve HTTP, save chat
|
||||||
|
messages, or run an agent.
|
||||||
|
|
||||||
|
## Phase 0 — project and session shell
|
||||||
|
|
||||||
|
Status: **implemented**.
|
||||||
|
|
||||||
- Show projects in a sidebar with their sessions nested underneath.
|
- Show projects in a sidebar with their sessions nested underneath.
|
||||||
- Create projects with a native macOS folder picker and a project-name dialog.
|
- Create projects with a native macOS folder picker and a project-name dialog.
|
||||||
- Create, select, and delete projects and sessions.
|
- Create, select, and delete projects and sessions.
|
||||||
|
- Use a Codex-inspired two-pane layout with embedded, action-specific SVG icons.
|
||||||
- Persist projects and sessions in SQLite under
|
- Persist projects and sessions in SQLite under
|
||||||
`~/Library/Application Support/DS4Server.rfc1437.de/`, using Diesel ORM
|
`~/Library/Application Support/DS4Server.rfc1437.de/`, using Diesel ORM
|
||||||
queries and embedded Diesel migrations.
|
queries and embedded Diesel migrations.
|
||||||
@@ -22,6 +41,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;
|
||||||
|
model downloads, 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`:
|
||||||
`open`, `close`, `create_session`, `sync`, `sample`, and KV save/load.
|
`open`, `close`, `create_session`, `sync`, `sample`, and KV save/load.
|
||||||
@@ -30,31 +52,76 @@ Exit criterion: restart the app and see the same project/session tree.
|
|||||||
3. Reuse the existing `.metal` kernels and preserve mmap-backed resident
|
3. Reuse the existing `.metal` kernels and preserve mmap-backed resident
|
||||||
loading plus the explicit SSD-streaming path. Keep Objective-C only at the
|
loading plus the explicit SSD-streaming path. Keep Objective-C only at the
|
||||||
Metal interop boundary.
|
Metal interop boundary.
|
||||||
4. Add a model library screen that records GGUF paths and loading options.
|
4. Keep one engine resident at most, matching DwarfStar's instance-lock and
|
||||||
Load only after the user chooses a model. Expose
|
memory assumptions.
|
||||||
`Unloaded → Loading → Ready/Failed → Unloading` state and progress in Iced.
|
5. Move engine work off the UI thread and support cooperative cancellation at
|
||||||
5. Keep one engine resident initially, matching DwarfStar's instance-lock and
|
|
||||||
memory assumptions. Switching models unloads the current engine first.
|
|
||||||
6. 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`.
|
||||||
|
|
||||||
Persist model references and tuning options in Application Support. For a
|
### Lazy lifecycle
|
||||||
sandboxed distribution, store security-scoped bookmarks rather than assuming
|
|
||||||
raw paths remain accessible.
|
- Start the application and the local HTTP endpoint with no model loaded.
|
||||||
|
- Acquire the configured model only when generation starts, whether initiated
|
||||||
|
by a local chat or an OpenAI-compatible endpoint request. Concurrent first
|
||||||
|
requests share the same load instead of opening the model more than once.
|
||||||
|
- Expose `Unloaded → Downloading → Loading → Ready → Unloading` plus failure
|
||||||
|
states and progress in the GUI. A request that triggered loading waits for
|
||||||
|
the shared load and receives a clear error if download or initialization
|
||||||
|
fails.
|
||||||
|
- Treat active local generations, endpoint requests, prefills, and tool turns
|
||||||
|
as model activity. Start the idle timer only when all activity has stopped.
|
||||||
|
- Unload the engine after the configured idle timeout. New work cancels a
|
||||||
|
pending unload; work arriving after unload transparently loads the model
|
||||||
|
again. Persisted transcripts and KV files remain available when model memory
|
||||||
|
is released.
|
||||||
|
- Changing the selected model prevents new work from using the old model and
|
||||||
|
unloads it as soon as current work reaches a safe boundary.
|
||||||
|
|
||||||
|
### Preferences (implemented)
|
||||||
|
|
||||||
|
- Provide a Preferences panel from the sidebar and the standard
|
||||||
|
macOS `Command-,` shortcut.
|
||||||
|
- 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
|
||||||
|
control for models that do not support DFlash.
|
||||||
|
- Let the user configure the inactivity timeout; default to 10 minutes.
|
||||||
|
- Persist preferences in the application SQLite database. Changing preferences
|
||||||
|
never loads a model by itself.
|
||||||
|
|
||||||
|
### Targeted downloads and storage
|
||||||
|
|
||||||
|
- Store managed model artifacts under
|
||||||
|
`~/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
|
||||||
|
project directory.
|
||||||
|
- Download only the selected model's main artifact. Download its DFlash
|
||||||
|
artifact only when DFlash is enabled; never prefetch other catalog models or
|
||||||
|
optional artifacts.
|
||||||
|
- Verify size/checksum before making a download usable, resume partial
|
||||||
|
downloads, and remove failed temporary files without touching a valid model.
|
||||||
|
- When the selected model changes or DFlash is disabled, offer removal of the
|
||||||
|
now-unused artifact; do not delete large existing downloads without explicit
|
||||||
|
confirmation.
|
||||||
|
|
||||||
Exit criterion: select a supported DeepSeek V4 or GLM 5.2 GGUF, load it without
|
Exit criterion: select a supported DeepSeek V4 or GLM 5.2 GGUF, load it without
|
||||||
blocking the UI, run a deterministic prompt, unload it, and pass the matching
|
blocking the UI only when a chat begins, run a deterministic prompt, unload it
|
||||||
`../ds4` token-output fixture.
|
after the configured idle timeout, and pass the matching `../ds4` token-output
|
||||||
|
fixture. Repeat through the local endpoint and verify both paths share the same
|
||||||
|
engine lifecycle.
|
||||||
|
|
||||||
## Phase 2 — OpenAI-compatible local endpoint
|
## Phase 2 — OpenAI-compatible local endpoint
|
||||||
|
|
||||||
1. Add a localhost-only HTTP service owned by the loaded engine. Start and stop
|
Status: **open; not started**.
|
||||||
it from the GUI; never expose a LAN listener without an explicit setting.
|
|
||||||
|
1. Add a localhost-only HTTP service whose lifecycle is independent of the
|
||||||
|
engine. It can listen while the model is unloaded and acquires the configured
|
||||||
|
model only for inference. Start and stop it from the GUI; never expose a LAN
|
||||||
|
listener without an explicit setting.
|
||||||
2. Implement the DwarfStar compatibility surface in this order:
|
2. Implement the DwarfStar compatibility surface in this order:
|
||||||
- `GET /v1/models`
|
- `GET /v1/models`
|
||||||
- `POST /v1/chat/completions`
|
- `POST /v1/chat/completions`
|
||||||
- `POST /v1/responses`
|
- `POST /v1/responses`
|
||||||
- `POST /v1/completions`
|
- `POST /v1/completions`
|
||||||
|
`GET /v1/models` reports the configured model without forcing it to load.
|
||||||
3. Support streaming SSE, reasoning output, usage accounting, cancellation,
|
3. Support streaming SSE, reasoning output, usage accounting, cancellation,
|
||||||
sampling parameters, tool schemas, and tool choice.
|
sampling parameters, tool schemas, and tool choice.
|
||||||
4. Port exact sampled tool-call replay and deterministic canonicalization from
|
4. Port exact sampled tool-call replay and deterministic canonicalization from
|
||||||
@@ -72,11 +139,17 @@ multi-turn tool call.
|
|||||||
|
|
||||||
## Phase 3 — durable agent sessions
|
## Phase 3 — durable agent sessions
|
||||||
|
|
||||||
|
Status: **partially open**. Project/session metadata rows exist; transcripts,
|
||||||
|
model binding, and KV payload persistence are not implemented.
|
||||||
|
|
||||||
Replace each metadata-only session with a directory:
|
Replace each metadata-only session with a directory:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Application Support/DS4Server.rfc1437.de/
|
Application Support/DS4Server.rfc1437.de/
|
||||||
data.sqlite3
|
data.sqlite3
|
||||||
|
models/<model-id>/
|
||||||
|
main.gguf
|
||||||
|
dflash.gguf # only when enabled
|
||||||
sessions/<session-id>/
|
sessions/<session-id>/
|
||||||
kv.bin
|
kv.bin
|
||||||
```
|
```
|
||||||
@@ -97,6 +170,9 @@ the same conversation without prefill when its KV payload is compatible.
|
|||||||
|
|
||||||
## Phase 4 — agent chat and tools
|
## Phase 4 — agent chat and tools
|
||||||
|
|
||||||
|
Status: **UI shell only**. The conversation area, composer, and icons are
|
||||||
|
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.
|
||||||
@@ -123,6 +199,11 @@ continue from the persisted session.
|
|||||||
|
|
||||||
## Verification and packaging
|
## Verification and packaging
|
||||||
|
|
||||||
|
Status: **partially implemented**. Formatting, Clippy, build, and unit-test
|
||||||
|
commit gates are documented in `AGENTS.md`; database migration/CRUD constraints
|
||||||
|
and the preferences shortcut have unit coverage. Model fixtures, integration
|
||||||
|
tests, and release packaging remain open.
|
||||||
|
|
||||||
- Keep unit coverage narrow: persistence round trips, format compatibility,
|
- Keep unit coverage narrow: persistence round trips, format compatibility,
|
||||||
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
|
||||||
@@ -131,3 +212,14 @@ continue from the persisted session.
|
|||||||
reused or adapted source and kernels.
|
reused or adapted source and kernels.
|
||||||
- Produce a signed `.app` with the exact identifier above, then add hardened
|
- Produce a signed `.app` with the exact identifier above, then add hardened
|
||||||
runtime, entitlements, notarization, and update delivery as release work.
|
runtime, entitlements, notarization, and update delivery as release work.
|
||||||
|
|
||||||
|
## Recommended next milestones
|
||||||
|
|
||||||
|
1. Add the targeted download manager and model catalog, storing only selected
|
||||||
|
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.
|
||||||
|
3. Put the OpenAI-compatible endpoint on that same engine lifecycle so local
|
||||||
|
chats and HTTP requests cannot load duplicate engines.
|
||||||
|
4. Add transcript/KV persistence, then connect the existing conversation UI and
|
||||||
|
finally port the agent tools.
|
||||||
|
|||||||
@@ -6,10 +6,14 @@ 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 and sessions are persisted through Diesel in SQLite.
|
name. Projects, sessions, and model preferences are persisted through Diesel in
|
||||||
|
SQLite. Open Preferences with `Command-,` to select the model, toggle DFlash,
|
||||||
|
and configure the model idle timeout.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cargo run
|
cargo install cargo-packager --locked --version 0.11.8
|
||||||
|
make bundle
|
||||||
|
open target/release/DS4Server.app
|
||||||
```
|
```
|
||||||
|
|
||||||
State is stored at:
|
State is stored at:
|
||||||
|
|||||||
BIN
assets/DS4Server.icns
Normal file
BIN
assets/DS4Server.icns
Normal file
Binary file not shown.
BIN
assets/app-icon.png
Normal file
BIN
assets/app-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
4
assets/icons/settings.svg
Normal file
4
assets/icons/settings.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="none" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M8.4 2.75h3.2l.55 2a6.3 6.3 0 0 1 1.2.7l2-.55 1.6 2.75-1.45 1.45q.1.9 0 1.8l1.45 1.45-1.6 2.75-2-.55a6.3 6.3 0 0 1-1.2.7l-.55 2H8.4l-.55-2a6.3 6.3 0 0 1-1.2-.7l-2 .55-1.6-2.75L4.5 10.9q-.1-.9 0-1.8L3.05 7.65l1.6-2.75 2 .55a6.3 6.3 0 0 1 1.2-.7z"/>
|
||||||
|
<circle cx="10" cy="10" r="2.25"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 456 B |
1
migrations/20260724140000_create_preferences/down.sql
Normal file
1
migrations/20260724140000_create_preferences/down.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE preferences;
|
||||||
12
migrations/20260724140000_create_preferences/up.sql
Normal file
12
migrations/20260724140000_create_preferences/up.sql
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
CREATE TABLE preferences (
|
||||||
|
id INTEGER PRIMARY KEY NOT NULL CHECK (id = 1),
|
||||||
|
selected_model TEXT NOT NULL DEFAULT 'deepseek-v4-flash'
|
||||||
|
CHECK (selected_model IN ('deepseek-v4-flash', 'deepseek-v4-pro', 'glm-5.2')),
|
||||||
|
dflash_enabled BOOLEAN NOT NULL DEFAULT 0
|
||||||
|
CHECK (dflash_enabled IN (0, 1)),
|
||||||
|
idle_timeout_minutes INTEGER NOT NULL DEFAULT 10
|
||||||
|
CHECK (idle_timeout_minutes BETWEEN 1 AND 1440),
|
||||||
|
CHECK (dflash_enabled = 0 OR selected_model = 'deepseek-v4-flash')
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO preferences (id) VALUES (1);
|
||||||
@@ -4,10 +4,39 @@ use std::collections::HashMap;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
use crate::schema::{projects, sessions};
|
use crate::schema::{preferences, projects, sessions};
|
||||||
|
|
||||||
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
|
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Identifiable, Queryable, Selectable)]
|
||||||
|
#[diesel(table_name = preferences)]
|
||||||
|
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
|
||||||
|
pub struct AppPreferences {
|
||||||
|
pub id: i32,
|
||||||
|
pub selected_model: String,
|
||||||
|
pub dflash_enabled: bool,
|
||||||
|
pub idle_timeout_minutes: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AppPreferences {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
id: 1,
|
||||||
|
selected_model: "deepseek-v4-flash".into(),
|
||||||
|
dflash_enabled: false,
|
||||||
|
idle_timeout_minutes: 10,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(AsChangeset)]
|
||||||
|
#[diesel(table_name = preferences)]
|
||||||
|
struct PreferenceChanges<'a> {
|
||||||
|
selected_model: &'a str,
|
||||||
|
dflash_enabled: bool,
|
||||||
|
idle_timeout_minutes: i32,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Identifiable, Queryable, Selectable)]
|
#[derive(Clone, Debug, Identifiable, Queryable, Selectable)]
|
||||||
#[diesel(table_name = projects)]
|
#[diesel(table_name = projects)]
|
||||||
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
|
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
|
||||||
@@ -95,6 +124,31 @@ impl Database {
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn load_preferences(&mut self) -> Result<AppPreferences, String> {
|
||||||
|
preferences::table
|
||||||
|
.find(1)
|
||||||
|
.select(AppPreferences::as_select())
|
||||||
|
.first(&mut self.connection)
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_preferences(
|
||||||
|
&mut self,
|
||||||
|
selected_model: &str,
|
||||||
|
dflash_enabled: bool,
|
||||||
|
idle_timeout_minutes: i32,
|
||||||
|
) -> Result<AppPreferences, String> {
|
||||||
|
diesel::update(preferences::table.find(1))
|
||||||
|
.set(PreferenceChanges {
|
||||||
|
selected_model,
|
||||||
|
dflash_enabled,
|
||||||
|
idle_timeout_minutes,
|
||||||
|
})
|
||||||
|
.returning(AppPreferences::as_returning())
|
||||||
|
.get_result(&mut self.connection)
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn create_project(&mut self, name: &str, path: &str) -> Result<Project, String> {
|
pub fn create_project(&mut self, name: &str, path: &str) -> Result<Project, String> {
|
||||||
diesel::insert_into(projects::table)
|
diesel::insert_into(projects::table)
|
||||||
.values(NewProject { name, path })
|
.values(NewProject { name, path })
|
||||||
@@ -144,6 +198,18 @@ mod tests {
|
|||||||
let path = std::env::temp_dir().join(format!("ds4-server-{id}.sqlite3"));
|
let path = std::env::temp_dir().join(format!("ds4-server-{id}.sqlite3"));
|
||||||
let mut database = Database::open(&path).unwrap();
|
let mut database = Database::open(&path).unwrap();
|
||||||
|
|
||||||
|
let preferences = database.load_preferences().unwrap();
|
||||||
|
assert_eq!(preferences.selected_model, "deepseek-v4-flash");
|
||||||
|
assert!(!preferences.dflash_enabled);
|
||||||
|
assert_eq!(preferences.idle_timeout_minutes, 10);
|
||||||
|
assert!(database.update_preferences("glm-5.2", true, 30).is_err());
|
||||||
|
assert!(
|
||||||
|
database
|
||||||
|
.update_preferences("deepseek-v4-flash", false, 0)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
database.update_preferences("glm-5.2", false, 30).unwrap();
|
||||||
|
|
||||||
let project = database.create_project("DS4", "/tmp/ds4").unwrap();
|
let project = database.create_project("DS4", "/tmp/ds4").unwrap();
|
||||||
let first = database
|
let first = database
|
||||||
.create_session(project.id, "First session")
|
.create_session(project.id, "First session")
|
||||||
@@ -158,6 +224,12 @@ mod tests {
|
|||||||
database.delete_project(project.id).unwrap();
|
database.delete_project(project.id).unwrap();
|
||||||
assert!(database.load_projects().unwrap().is_empty());
|
assert!(database.load_projects().unwrap().is_empty());
|
||||||
drop(database);
|
drop(database);
|
||||||
|
|
||||||
|
let mut reopened = Database::open(&path).unwrap();
|
||||||
|
let preferences = reopened.load_preferences().unwrap();
|
||||||
|
assert_eq!(preferences.selected_model, "glm-5.2");
|
||||||
|
assert_eq!(preferences.idle_timeout_minutes, 30);
|
||||||
|
drop(reopened);
|
||||||
fs::remove_file(path).unwrap();
|
fs::remove_file(path).unwrap();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
395
src/main.rs
395
src/main.rs
@@ -1,13 +1,15 @@
|
|||||||
mod database;
|
mod database;
|
||||||
mod schema;
|
mod schema;
|
||||||
|
|
||||||
use database::{Database, ProjectWithSessions, Session};
|
use database::{AppPreferences, Database, ProjectWithSessions, Session};
|
||||||
use iced::theme::Palette;
|
use iced::theme::{Palette, palette};
|
||||||
use iced::widget::{
|
use iced::widget::{
|
||||||
Space, Svg, button, column, container, opaque, row, scrollable, stack, svg, text, text_input,
|
Space, Svg, button, checkbox, column, container, opaque, pick_list, row, scrollable, stack,
|
||||||
|
svg, text, text_input,
|
||||||
};
|
};
|
||||||
use iced::{Alignment, Color, Element, Length, Size, Task, Theme};
|
use iced::{Alignment, Color, Element, Length, Size, Subscription, Task, Theme, keyboard};
|
||||||
use rfd::AsyncFileDialog;
|
use rfd::AsyncFileDialog;
|
||||||
|
use std::fmt;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
@@ -16,7 +18,7 @@ const ICON_FOLDER: &[u8] = include_bytes!("../assets/icons/folder.svg");
|
|||||||
const ICON_FOLDER_PLUS: &[u8] = include_bytes!("../assets/icons/folder-plus.svg");
|
const ICON_FOLDER_PLUS: &[u8] = include_bytes!("../assets/icons/folder-plus.svg");
|
||||||
const ICON_NEW_SESSION: &[u8] = include_bytes!("../assets/icons/new-session.svg");
|
const ICON_NEW_SESSION: &[u8] = include_bytes!("../assets/icons/new-session.svg");
|
||||||
const ICON_CHAT: &[u8] = include_bytes!("../assets/icons/chat.svg");
|
const ICON_CHAT: &[u8] = include_bytes!("../assets/icons/chat.svg");
|
||||||
const ICON_SEARCH: &[u8] = include_bytes!("../assets/icons/search.svg");
|
const ICON_SETTINGS: &[u8] = include_bytes!("../assets/icons/settings.svg");
|
||||||
const ICON_TRASH: &[u8] = include_bytes!("../assets/icons/trash.svg");
|
const ICON_TRASH: &[u8] = include_bytes!("../assets/icons/trash.svg");
|
||||||
const ICON_MORE: &[u8] = include_bytes!("../assets/icons/more.svg");
|
const ICON_MORE: &[u8] = include_bytes!("../assets/icons/more.svg");
|
||||||
const ICON_PAPERCLIP: &[u8] = include_bytes!("../assets/icons/paperclip.svg");
|
const ICON_PAPERCLIP: &[u8] = include_bytes!("../assets/icons/paperclip.svg");
|
||||||
@@ -24,20 +26,71 @@ const ICON_SEND: &[u8] = include_bytes!("../assets/icons/send.svg");
|
|||||||
const ICON_MODEL: &[u8] = include_bytes!("../assets/icons/model.svg");
|
const ICON_MODEL: &[u8] = include_bytes!("../assets/icons/model.svg");
|
||||||
const ICON_SPARK: &[u8] = include_bytes!("../assets/icons/spark.svg");
|
const ICON_SPARK: &[u8] = include_bytes!("../assets/icons/spark.svg");
|
||||||
|
|
||||||
|
const MODEL_CHOICES: [ModelChoice; 3] = [
|
||||||
|
ModelChoice::DeepSeekV4Flash,
|
||||||
|
ModelChoice::DeepSeekV4Pro,
|
||||||
|
ModelChoice::Glm52,
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
|
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)]
|
||||||
|
struct PreferenceDraft {
|
||||||
|
model: ModelChoice,
|
||||||
|
dflash_enabled: bool,
|
||||||
|
idle_timeout_minutes: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PreferenceDraft {
|
||||||
|
fn from_saved(preferences: &AppPreferences) -> Result<Self, String> {
|
||||||
|
let model = ModelChoice::from_id(&preferences.selected_model)
|
||||||
|
.ok_or_else(|| format!("Unsupported model: {}", preferences.selected_model))?;
|
||||||
|
Ok(Self {
|
||||||
|
model,
|
||||||
|
dflash_enabled: model.supports_dflash() && preferences.dflash_enabled,
|
||||||
|
idle_timeout_minutes: preferences.idle_timeout_minutes.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn main() -> iced::Result {
|
fn main() -> iced::Result {
|
||||||
iced::application("DS4Server", App::update, App::view)
|
iced::application("DS4Server", App::update, App::view)
|
||||||
.theme(|_| {
|
.subscription(App::subscription)
|
||||||
Theme::custom(
|
.theme(|_| app_theme())
|
||||||
"DS4Server".into(),
|
|
||||||
Palette {
|
|
||||||
background: Color::from_rgb8(29, 29, 31),
|
|
||||||
text: Color::from_rgb8(235, 235, 235),
|
|
||||||
primary: Color::from_rgb8(85, 118, 255),
|
|
||||||
success: Color::from_rgb8(72, 176, 112),
|
|
||||||
danger: Color::from_rgb8(220, 80, 86),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.window(iced::window::Settings {
|
.window(iced::window::Settings {
|
||||||
size: Size::new(1120.0, 720.0),
|
size: Size::new(1120.0, 720.0),
|
||||||
min_size: Some(Size::new(760.0, 480.0)),
|
min_size: Some(Size::new(760.0, 480.0)),
|
||||||
@@ -49,17 +102,26 @@ fn main() -> iced::Result {
|
|||||||
struct App {
|
struct App {
|
||||||
database: Option<Database>,
|
database: Option<Database>,
|
||||||
projects: Vec<ProjectWithSessions>,
|
projects: Vec<ProjectWithSessions>,
|
||||||
|
preferences: AppPreferences,
|
||||||
|
preference_draft: PreferenceDraft,
|
||||||
|
preferences_open: bool,
|
||||||
|
preference_error: Option<String>,
|
||||||
selected_project: Option<i32>,
|
selected_project: Option<i32>,
|
||||||
selected_session: Option<i32>,
|
selected_session: Option<i32>,
|
||||||
choosing_folder: bool,
|
choosing_folder: bool,
|
||||||
pending_project_path: Option<PathBuf>,
|
pending_project_path: Option<PathBuf>,
|
||||||
project_name_input: String,
|
project_name_input: String,
|
||||||
session_title_input: String,
|
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
enum Message {
|
enum Message {
|
||||||
|
OpenPreferences,
|
||||||
|
DismissPanel,
|
||||||
|
PreferenceModelChanged(ModelChoice),
|
||||||
|
PreferenceDflashChanged(bool),
|
||||||
|
PreferenceTimeoutChanged(String),
|
||||||
|
SavePreferences,
|
||||||
ChooseProjectFolder,
|
ChooseProjectFolder,
|
||||||
ProjectFolderPicked(Option<PathBuf>),
|
ProjectFolderPicked(Option<PathBuf>),
|
||||||
ProjectNameChanged(String),
|
ProjectNameChanged(String),
|
||||||
@@ -67,7 +129,6 @@ enum Message {
|
|||||||
CancelProject,
|
CancelProject,
|
||||||
SelectProject(i32),
|
SelectProject(i32),
|
||||||
DeleteProject(i32),
|
DeleteProject(i32),
|
||||||
SessionTitleChanged(String),
|
|
||||||
CreateSession,
|
CreateSession,
|
||||||
SelectSession(i32, i32),
|
SelectSession(i32, i32),
|
||||||
DeleteSession(i32),
|
DeleteSession(i32),
|
||||||
@@ -77,40 +138,83 @@ impl App {
|
|||||||
fn load() -> Self {
|
fn load() -> Self {
|
||||||
let path = application_support_path().join("data.sqlite3");
|
let path = application_support_path().join("data.sqlite3");
|
||||||
match Database::open(&path) {
|
match Database::open(&path) {
|
||||||
Ok(mut database) => match database.load_projects() {
|
Ok(mut database) => match (database.load_projects(), database.load_preferences()) {
|
||||||
Ok(projects) => Self {
|
(Ok(projects), Ok(preferences)) => {
|
||||||
|
let preference_draft = match PreferenceDraft::from_saved(&preferences) {
|
||||||
|
Ok(draft) => draft,
|
||||||
|
Err(error) => return Self::failed(error),
|
||||||
|
};
|
||||||
|
Self {
|
||||||
database: Some(database),
|
database: Some(database),
|
||||||
projects,
|
projects,
|
||||||
|
preferences,
|
||||||
|
preference_draft,
|
||||||
|
preferences_open: false,
|
||||||
|
preference_error: None,
|
||||||
selected_project: None,
|
selected_project: None,
|
||||||
selected_session: None,
|
selected_session: None,
|
||||||
choosing_folder: false,
|
choosing_folder: false,
|
||||||
pending_project_path: None,
|
pending_project_path: None,
|
||||||
project_name_input: String::new(),
|
project_name_input: String::new(),
|
||||||
session_title_input: String::new(),
|
|
||||||
error: None,
|
error: None,
|
||||||
},
|
}
|
||||||
Err(error) => Self::failed(error),
|
}
|
||||||
|
(Err(error), _) | (_, Err(error)) => Self::failed(error),
|
||||||
},
|
},
|
||||||
Err(error) => Self::failed(error),
|
Err(error) => Self::failed(error),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn failed(error: String) -> Self {
|
fn failed(error: String) -> Self {
|
||||||
|
let preferences = AppPreferences::default();
|
||||||
|
let preference_draft = PreferenceDraft::from_saved(&preferences)
|
||||||
|
.expect("default preferences must use a supported model");
|
||||||
Self {
|
Self {
|
||||||
database: None,
|
database: None,
|
||||||
projects: Vec::new(),
|
projects: Vec::new(),
|
||||||
|
preferences,
|
||||||
|
preference_draft,
|
||||||
|
preferences_open: false,
|
||||||
|
preference_error: None,
|
||||||
selected_project: None,
|
selected_project: None,
|
||||||
selected_session: None,
|
selected_session: None,
|
||||||
choosing_folder: false,
|
choosing_folder: false,
|
||||||
pending_project_path: None,
|
pending_project_path: None,
|
||||||
project_name_input: String::new(),
|
project_name_input: String::new(),
|
||||||
session_title_input: String::new(),
|
|
||||||
error: Some(format!("Could not open the project database: {error}")),
|
error: Some(format!("Could not open the project database: {error}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(&mut self, message: Message) -> Task<Message> {
|
fn update(&mut self, message: Message) -> Task<Message> {
|
||||||
match message {
|
match message {
|
||||||
|
Message::OpenPreferences => self.open_preferences(),
|
||||||
|
Message::DismissPanel => {
|
||||||
|
if self.preferences_open {
|
||||||
|
self.preferences_open = false;
|
||||||
|
self.preference_error = None;
|
||||||
|
} else if self.pending_project_path.is_some() {
|
||||||
|
self.pending_project_path = None;
|
||||||
|
self.project_name_input.clear();
|
||||||
|
self.error = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::PreferenceModelChanged(model) => {
|
||||||
|
self.preference_draft.model = model;
|
||||||
|
if !model.supports_dflash() {
|
||||||
|
self.preference_draft.dflash_enabled = false;
|
||||||
|
}
|
||||||
|
self.preference_error = None;
|
||||||
|
}
|
||||||
|
Message::PreferenceDflashChanged(enabled) => {
|
||||||
|
self.preference_draft.dflash_enabled =
|
||||||
|
self.preference_draft.model.supports_dflash() && enabled;
|
||||||
|
self.preference_error = None;
|
||||||
|
}
|
||||||
|
Message::PreferenceTimeoutChanged(value) => {
|
||||||
|
self.preference_draft.idle_timeout_minutes = value;
|
||||||
|
self.preference_error = None;
|
||||||
|
}
|
||||||
|
Message::SavePreferences => self.save_preferences(),
|
||||||
Message::ChooseProjectFolder => {
|
Message::ChooseProjectFolder => {
|
||||||
self.choosing_folder = true;
|
self.choosing_folder = true;
|
||||||
return Task::perform(
|
return Task::perform(
|
||||||
@@ -156,7 +260,6 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Message::SessionTitleChanged(value) => self.session_title_input = value,
|
|
||||||
Message::CreateSession => self.create_session(),
|
Message::CreateSession => self.create_session(),
|
||||||
Message::SelectSession(project_id, session_id) => {
|
Message::SelectSession(project_id, session_id) => {
|
||||||
self.selected_project = Some(project_id);
|
self.selected_project = Some(project_id);
|
||||||
@@ -180,6 +283,57 @@ impl App {
|
|||||||
Task::none()
|
Task::none()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn subscription(&self) -> Subscription<Message> {
|
||||||
|
keyboard::on_key_press(shortcut)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_preferences(&mut self) {
|
||||||
|
if self.database.is_none() || self.pending_project_path.is_some() || self.choosing_folder {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match PreferenceDraft::from_saved(&self.preferences) {
|
||||||
|
Ok(draft) => {
|
||||||
|
self.preference_draft = draft;
|
||||||
|
self.preference_error = None;
|
||||||
|
self.preferences_open = true;
|
||||||
|
}
|
||||||
|
Err(error) => self.error = Some(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_preferences(&mut self) {
|
||||||
|
let Ok(idle_timeout_minutes) = self
|
||||||
|
.preference_draft
|
||||||
|
.idle_timeout_minutes
|
||||||
|
.trim()
|
||||||
|
.parse::<i32>()
|
||||||
|
else {
|
||||||
|
self.preference_error = Some("Idle timeout must be a whole number.".into());
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !(1..=1440).contains(&idle_timeout_minutes) {
|
||||||
|
self.preference_error = Some("Idle timeout must be between 1 and 1440 minutes.".into());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let model = self.preference_draft.model;
|
||||||
|
let dflash_enabled = model.supports_dflash() && self.preference_draft.dflash_enabled;
|
||||||
|
let Some(database) = &mut self.database else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match database.update_preferences(model.id(), dflash_enabled, idle_timeout_minutes) {
|
||||||
|
Ok(preferences) => {
|
||||||
|
self.preferences = preferences;
|
||||||
|
self.preference_draft = PreferenceDraft::from_saved(&self.preferences)
|
||||||
|
.expect("the saved model was selected from the supported catalog");
|
||||||
|
self.preferences_open = false;
|
||||||
|
self.preference_error = None;
|
||||||
|
self.error = None;
|
||||||
|
}
|
||||||
|
Err(error) => self.preference_error = Some(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn prepare_project(&mut self, path: PathBuf) {
|
fn prepare_project(&mut self, path: PathBuf) {
|
||||||
let Ok(path) = fs::canonicalize(path) else {
|
let Ok(path) = fs::canonicalize(path) else {
|
||||||
self.error = Some("The selected folder is no longer available.".into());
|
self.error = Some("The selected folder is no longer available.".into());
|
||||||
@@ -246,10 +400,7 @@ impl App {
|
|||||||
.selected_project()
|
.selected_project()
|
||||||
.map(|project| project.sessions.len() + 1)
|
.map(|project| project.sessions.len() + 1)
|
||||||
.unwrap_or(1);
|
.unwrap_or(1);
|
||||||
let title = match self.session_title_input.trim() {
|
let title = format!("Session {default_number}");
|
||||||
"" => format!("Session {default_number}"),
|
|
||||||
title => title.to_owned(),
|
|
||||||
};
|
|
||||||
let Some(database) = &mut self.database else {
|
let Some(database) = &mut self.database else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -257,7 +408,6 @@ impl App {
|
|||||||
match database.create_session(project_id, &title) {
|
match database.create_session(project_id, &title) {
|
||||||
Ok(session) => {
|
Ok(session) => {
|
||||||
self.selected_session = Some(session.id);
|
self.selected_session = Some(session.id);
|
||||||
self.session_title_input.clear();
|
|
||||||
self.error = None;
|
self.error = None;
|
||||||
self.reload_projects();
|
self.reload_projects();
|
||||||
}
|
}
|
||||||
@@ -290,7 +440,9 @@ impl App {
|
|||||||
let content: Element<'_, Message> = shell.into();
|
let content: Element<'_, Message> = shell.into();
|
||||||
let mut layers = vec![content];
|
let mut layers = vec![content];
|
||||||
|
|
||||||
if let Some(path) = &self.pending_project_path {
|
if self.preferences_open {
|
||||||
|
layers.push(self.preferences_panel());
|
||||||
|
} else if let Some(path) = &self.pending_project_path {
|
||||||
layers.push(self.project_dialog(path));
|
layers.push(self.project_dialog(path));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,6 +463,21 @@ impl App {
|
|||||||
} else {
|
} else {
|
||||||
button(new_session_content).width(Length::Fill)
|
button(new_session_content).width(Length::Fill)
|
||||||
};
|
};
|
||||||
|
let preference_content = row![
|
||||||
|
icon(ICON_SETTINGS, 17),
|
||||||
|
text("Preferences").size(14),
|
||||||
|
Space::with_width(Length::Fill),
|
||||||
|
text("⌘,").size(12),
|
||||||
|
]
|
||||||
|
.spacing(9)
|
||||||
|
.align_y(Alignment::Center);
|
||||||
|
let preferences = if self.database.is_some() {
|
||||||
|
button(preference_content)
|
||||||
|
.width(Length::Fill)
|
||||||
|
.on_press(Message::OpenPreferences)
|
||||||
|
} else {
|
||||||
|
button(preference_content).width(Length::Fill)
|
||||||
|
};
|
||||||
let add_project_content = row![icon(ICON_FOLDER_PLUS, 17), text("Add project…").size(14),]
|
let add_project_content = row![icon(ICON_FOLDER_PLUS, 17), text("Add project…").size(14),]
|
||||||
.spacing(9)
|
.spacing(9)
|
||||||
.align_y(Alignment::Center);
|
.align_y(Alignment::Center);
|
||||||
@@ -321,14 +488,17 @@ impl App {
|
|||||||
.width(Length::Fill)
|
.width(Length::Fill)
|
||||||
.on_press(Message::ChooseProjectFolder)
|
.on_press(Message::ChooseProjectFolder)
|
||||||
};
|
};
|
||||||
let mut projects = column![
|
let header = column![
|
||||||
row![
|
row![
|
||||||
text("DS4Server").size(20),
|
text("DS4Server").size(20),
|
||||||
Space::with_width(Length::Fill),
|
Space::with_width(Length::Fill),
|
||||||
icon(ICON_SEARCH, 18),
|
text("Local").size(12),
|
||||||
]
|
]
|
||||||
.align_y(Alignment::Center),
|
.align_y(Alignment::Center),
|
||||||
new_session.style(button::text),
|
new_session.style(button::text),
|
||||||
|
]
|
||||||
|
.spacing(10);
|
||||||
|
let mut projects = column![
|
||||||
row![
|
row![
|
||||||
text("PROJECTS").size(11),
|
text("PROJECTS").size(11),
|
||||||
Space::with_width(Length::Fill),
|
Space::with_width(Length::Fill),
|
||||||
@@ -386,7 +556,14 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
container(scrollable(projects).height(Length::Fill))
|
container(
|
||||||
|
column![
|
||||||
|
header,
|
||||||
|
scrollable(projects).height(Length::Fill),
|
||||||
|
preferences.style(button::text),
|
||||||
|
]
|
||||||
|
.spacing(10),
|
||||||
|
)
|
||||||
.width(276)
|
.width(276)
|
||||||
.height(Length::Fill)
|
.height(Length::Fill)
|
||||||
.padding(16)
|
.padding(16)
|
||||||
@@ -432,14 +609,6 @@ impl App {
|
|||||||
text(selected_title).size(18),
|
text(selected_title).size(18),
|
||||||
icon(ICON_MORE, 18),
|
icon(ICON_MORE, 18),
|
||||||
Space::with_width(Length::Fill),
|
Space::with_width(Length::Fill),
|
||||||
text_input("Session title", &self.session_title_input)
|
|
||||||
.on_input(Message::SessionTitleChanged)
|
|
||||||
.on_submit(Message::CreateSession)
|
|
||||||
.width(190)
|
|
||||||
.padding(8),
|
|
||||||
button("New session")
|
|
||||||
.on_press(Message::CreateSession)
|
|
||||||
.style(button::primary),
|
|
||||||
]
|
]
|
||||||
.spacing(10)
|
.spacing(10)
|
||||||
.align_y(Alignment::Center);
|
.align_y(Alignment::Center);
|
||||||
@@ -461,7 +630,12 @@ impl App {
|
|||||||
icon(ICON_PAPERCLIP, 19),
|
icon(ICON_PAPERCLIP, 19),
|
||||||
Space::with_width(Length::Fill),
|
Space::with_width(Length::Fill),
|
||||||
icon(ICON_MODEL, 16),
|
icon(ICON_MODEL, 16),
|
||||||
text("Local model").size(12),
|
text(
|
||||||
|
ModelChoice::from_id(&self.preferences.selected_model)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
.size(12),
|
||||||
button(icon(ICON_SEND, 18)),
|
button(icon(ICON_SEND, 18)),
|
||||||
]
|
]
|
||||||
.align_y(Alignment::Center),
|
.align_y(Alignment::Center),
|
||||||
@@ -505,6 +679,87 @@ impl App {
|
|||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn preferences_panel(&self) -> Element<'_, Message> {
|
||||||
|
let dflash_toggle: Option<fn(bool) -> Message> = self
|
||||||
|
.preference_draft
|
||||||
|
.model
|
||||||
|
.supports_dflash()
|
||||||
|
.then_some(Message::PreferenceDflashChanged);
|
||||||
|
let dflash = checkbox(
|
||||||
|
"Enable DFlash for this model",
|
||||||
|
self.preference_draft.dflash_enabled,
|
||||||
|
)
|
||||||
|
.on_toggle_maybe(dflash_toggle);
|
||||||
|
|
||||||
|
let mut content = column![
|
||||||
|
row![
|
||||||
|
icon(ICON_SETTINGS, 22),
|
||||||
|
text("Preferences").size(24),
|
||||||
|
Space::with_width(Length::Fill),
|
||||||
|
text("⌘,").size(12),
|
||||||
|
]
|
||||||
|
.spacing(10)
|
||||||
|
.align_y(Alignment::Center),
|
||||||
|
Space::with_height(6),
|
||||||
|
text("MODEL").size(11),
|
||||||
|
pick_list(
|
||||||
|
&MODEL_CHOICES[..],
|
||||||
|
Some(self.preference_draft.model),
|
||||||
|
Message::PreferenceModelChanged,
|
||||||
|
)
|
||||||
|
.width(Length::Fill),
|
||||||
|
dflash,
|
||||||
|
text(if self.preference_draft.model.supports_dflash() {
|
||||||
|
"DFlash is downloaded and used only when enabled."
|
||||||
|
} else {
|
||||||
|
"DFlash is not available for the selected model."
|
||||||
|
})
|
||||||
|
.size(12),
|
||||||
|
Space::with_height(8),
|
||||||
|
text("INACTIVITY").size(11),
|
||||||
|
row![
|
||||||
|
text_input("10", &self.preference_draft.idle_timeout_minutes)
|
||||||
|
.on_input(Message::PreferenceTimeoutChanged)
|
||||||
|
.width(90)
|
||||||
|
.padding(9),
|
||||||
|
text("minutes before unloading the model").size(13),
|
||||||
|
]
|
||||||
|
.spacing(10)
|
||||||
|
.align_y(Alignment::Center),
|
||||||
|
text("Enter a whole number from 1 to 1440.").size(12),
|
||||||
|
]
|
||||||
|
.spacing(10);
|
||||||
|
|
||||||
|
if let Some(error) = &self.preference_error {
|
||||||
|
content = content.push(text(error).style(iced::widget::text::danger));
|
||||||
|
}
|
||||||
|
content = content.push(
|
||||||
|
row![
|
||||||
|
Space::with_width(Length::Fill),
|
||||||
|
button("Cancel")
|
||||||
|
.on_press(Message::DismissPanel)
|
||||||
|
.style(button::secondary),
|
||||||
|
button("Save")
|
||||||
|
.on_press(Message::SavePreferences)
|
||||||
|
.style(button::primary),
|
||||||
|
]
|
||||||
|
.spacing(8),
|
||||||
|
);
|
||||||
|
|
||||||
|
let panel = container(content)
|
||||||
|
.padding(24)
|
||||||
|
.width(520)
|
||||||
|
.style(container::rounded_box);
|
||||||
|
opaque(
|
||||||
|
container(panel)
|
||||||
|
.center_x(Length::Fill)
|
||||||
|
.center_y(Length::Fill)
|
||||||
|
.style(|_| {
|
||||||
|
container::Style::default().background(Color::from_rgba8(0, 0, 0, 0.68))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn project_dialog<'a>(&'a self, path: &'a Path) -> Element<'a, Message> {
|
fn project_dialog<'a>(&'a self, path: &'a Path) -> Element<'a, Message> {
|
||||||
let dialog = container(
|
let dialog = container(
|
||||||
column![
|
column![
|
||||||
@@ -556,6 +811,33 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn shortcut(key: keyboard::Key, modifiers: keyboard::Modifiers) -> Option<Message> {
|
||||||
|
match key.as_ref() {
|
||||||
|
keyboard::Key::Character(",") if modifiers.command() => Some(Message::OpenPreferences),
|
||||||
|
keyboard::Key::Named(keyboard::key::Named::Escape) => Some(Message::DismissPanel),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn app_theme() -> Theme {
|
||||||
|
let palette = Palette {
|
||||||
|
background: Color::from_rgb8(29, 29, 31),
|
||||||
|
text: Color::from_rgb8(235, 235, 235),
|
||||||
|
primary: Color::from_rgb8(85, 118, 255),
|
||||||
|
success: Color::from_rgb8(72, 176, 112),
|
||||||
|
danger: Color::from_rgb8(220, 80, 86),
|
||||||
|
};
|
||||||
|
Theme::custom_with_fn("DS4Server".into(), palette, |palette| {
|
||||||
|
let mut extended = palette::Extended::generate(palette);
|
||||||
|
extended.background.weak = palette::Pair::new(Color::from_rgb8(38, 38, 40), palette.text);
|
||||||
|
extended.background.strong = palette::Pair::new(Color::from_rgb8(58, 58, 61), palette.text);
|
||||||
|
extended.secondary.base = palette::Pair::new(Color::from_rgb8(47, 47, 50), palette.text);
|
||||||
|
extended.secondary.weak = palette::Pair::new(Color::from_rgb8(41, 41, 44), palette.text);
|
||||||
|
extended.secondary.strong = palette::Pair::new(Color::from_rgb8(57, 57, 60), palette.text);
|
||||||
|
extended
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn sidebar_style(_: &Theme) -> container::Style {
|
fn sidebar_style(_: &Theme) -> container::Style {
|
||||||
container::Style::default().background(Color::from_rgb8(23, 23, 25))
|
container::Style::default().background(Color::from_rgb8(23, 23, 25))
|
||||||
}
|
}
|
||||||
@@ -577,3 +859,28 @@ fn application_support_path() -> PathBuf {
|
|||||||
.join("Application Support")
|
.join("Application Support")
|
||||||
.join(APP_ID)
|
.join(APP_ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn preferences_shortcut_and_dflash_support_are_explicit() {
|
||||||
|
let message = shortcut(
|
||||||
|
keyboard::Key::Character(",".into()),
|
||||||
|
keyboard::Modifiers::COMMAND,
|
||||||
|
);
|
||||||
|
assert!(matches!(message, Some(Message::OpenPreferences)));
|
||||||
|
assert!(ModelChoice::DeepSeekV4Flash.supports_dflash());
|
||||||
|
assert!(!ModelChoice::DeepSeekV4Pro.supports_dflash());
|
||||||
|
assert!(!ModelChoice::Glm52.supports_dflash());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn raised_surfaces_stay_dark() {
|
||||||
|
let theme = app_theme();
|
||||||
|
let palette = theme.extended_palette();
|
||||||
|
assert_eq!(palette.background.weak.color, Color::from_rgb8(38, 38, 40));
|
||||||
|
assert_eq!(palette.secondary.base.color, Color::from_rgb8(47, 47, 50));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,15 @@ diesel::table! {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
preferences (id) {
|
||||||
|
id -> Integer,
|
||||||
|
selected_model -> Text,
|
||||||
|
dflash_enabled -> Bool,
|
||||||
|
idle_timeout_minutes -> Integer,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
diesel::table! {
|
diesel::table! {
|
||||||
sessions (id) {
|
sessions (id) {
|
||||||
id -> Integer,
|
id -> Integer,
|
||||||
@@ -15,4 +24,4 @@ diesel::table! {
|
|||||||
}
|
}
|
||||||
|
|
||||||
diesel::joinable!(sessions -> projects (project_id));
|
diesel::joinable!(sessions -> projects (project_id));
|
||||||
diesel::allow_tables_to_appear_in_same_query!(projects, sessions);
|
diesel::allow_tables_to_appear_in_same_query!(preferences, projects, sessions);
|
||||||
|
|||||||
Reference in New Issue
Block a user