960 lines
35 KiB
Rust
960 lines
35 KiB
Rust
use diesel::prelude::*;
|
|
use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations};
|
|
use std::collections::HashMap;
|
|
use std::fs;
|
|
use std::path::Path;
|
|
|
|
use crate::schema::{messages, preferences, projects, sessions};
|
|
use crate::settings::{
|
|
DiagnosticPreferences, ExecutionPreferences, GenerationPreferences, ReasoningMode,
|
|
RuntimePreferences, SpeculativePreferences, SsdPreferences, SteeringPreferences,
|
|
StreamingCacheBudget,
|
|
};
|
|
|
|
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 dspark_enabled: bool,
|
|
pub idle_timeout_minutes: i32,
|
|
pub context_tokens: i32,
|
|
pub max_generated_tokens: i32,
|
|
pub system_prompt: String,
|
|
pub temperature: Option<f32>,
|
|
pub top_p: Option<f32>,
|
|
pub min_p: Option<f32>,
|
|
pub seed: Option<String>,
|
|
pub reasoning_mode: String,
|
|
pub cpu_threads: Option<i32>,
|
|
pub power_percent: Option<i32>,
|
|
pub prefill_chunk: Option<i32>,
|
|
pub quality: bool,
|
|
pub warm_weights: bool,
|
|
pub mtp_draft_tokens: i32,
|
|
pub mtp_margin: f32,
|
|
pub glm_mtp: bool,
|
|
pub glm_mtp_timing: bool,
|
|
pub dspark_confidence_threshold: Option<f32>,
|
|
pub dspark_strict: bool,
|
|
pub ssd_streaming: bool,
|
|
pub ssd_streaming_cold: bool,
|
|
pub ssd_cache_experts: Option<i64>,
|
|
pub ssd_cache_gib: Option<i64>,
|
|
pub ssd_full_layers: Option<i32>,
|
|
pub ssd_preload_experts: Option<i32>,
|
|
pub directional_steering_file: Option<String>,
|
|
pub directional_steering_ffn: Option<f32>,
|
|
pub directional_steering_attn: Option<f32>,
|
|
pub simulated_used_memory_gib: Option<i64>,
|
|
pub expert_profile_path: Option<String>,
|
|
pub endpoint_port: i32,
|
|
pub endpoint_enabled: bool,
|
|
pub endpoint_cors: bool,
|
|
pub sidebar_collapsed: bool,
|
|
/// Project the app reopens on. Cleared when that project goes away.
|
|
pub last_project_id: Option<i32>,
|
|
pub sidebar_width: i32,
|
|
}
|
|
|
|
impl Default for AppPreferences {
|
|
fn default() -> Self {
|
|
Self {
|
|
id: 1,
|
|
selected_model: "deepseek-v4-flash".into(),
|
|
dspark_enabled: false,
|
|
idle_timeout_minutes: 10,
|
|
context_tokens: 32_768,
|
|
max_generated_tokens: 50_000,
|
|
system_prompt: "You are a helpful assistant".into(),
|
|
temperature: None,
|
|
top_p: None,
|
|
min_p: None,
|
|
seed: None,
|
|
reasoning_mode: "high".into(),
|
|
cpu_threads: None,
|
|
power_percent: None,
|
|
prefill_chunk: None,
|
|
quality: false,
|
|
warm_weights: false,
|
|
mtp_draft_tokens: 1,
|
|
mtp_margin: 3.0,
|
|
glm_mtp: false,
|
|
glm_mtp_timing: false,
|
|
dspark_confidence_threshold: None,
|
|
dspark_strict: false,
|
|
ssd_streaming: false,
|
|
ssd_streaming_cold: false,
|
|
ssd_cache_experts: None,
|
|
ssd_cache_gib: None,
|
|
ssd_full_layers: None,
|
|
ssd_preload_experts: None,
|
|
directional_steering_file: None,
|
|
directional_steering_ffn: None,
|
|
directional_steering_attn: None,
|
|
simulated_used_memory_gib: None,
|
|
expert_profile_path: None,
|
|
endpoint_port: 4000,
|
|
endpoint_enabled: true,
|
|
endpoint_cors: false,
|
|
sidebar_collapsed: false,
|
|
last_project_id: None,
|
|
sidebar_width: 276,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl AppPreferences {
|
|
pub(crate) fn generation(&self) -> Result<GenerationPreferences, String> {
|
|
let preferences = GenerationPreferences {
|
|
context_tokens: self.context_tokens,
|
|
max_generated_tokens: self.max_generated_tokens,
|
|
system_prompt: self.system_prompt.clone(),
|
|
temperature: self.temperature,
|
|
top_p: self.top_p,
|
|
min_p: self.min_p,
|
|
seed: self
|
|
.seed
|
|
.as_deref()
|
|
.map(str::parse)
|
|
.transpose()
|
|
.map_err(|_| "Saved seed is not a valid positive whole number.".to_owned())?,
|
|
reasoning_mode: ReasoningMode::from_id(&self.reasoning_mode)
|
|
.ok_or_else(|| format!("Unsupported reasoning mode: {}", self.reasoning_mode))?,
|
|
};
|
|
preferences.validate()?;
|
|
Ok(preferences)
|
|
}
|
|
|
|
pub(crate) fn execution(&self) -> Result<ExecutionPreferences, String> {
|
|
Ok(ExecutionPreferences {
|
|
cpu_threads: self
|
|
.cpu_threads
|
|
.map(u32::try_from)
|
|
.transpose()
|
|
.map_err(|_| "Saved CPU helper threads is invalid.".to_owned())?,
|
|
power_percent: self
|
|
.power_percent
|
|
.map(u8::try_from)
|
|
.transpose()
|
|
.map_err(|_| "Saved GPU power is invalid.".to_owned())?,
|
|
prefill_chunk: self
|
|
.prefill_chunk
|
|
.map(u32::try_from)
|
|
.transpose()
|
|
.map_err(|_| "Saved prefill chunk is invalid.".to_owned())?,
|
|
quality: self.quality,
|
|
warm_weights: self.warm_weights,
|
|
})
|
|
}
|
|
|
|
pub(crate) fn speculative(&self) -> SpeculativePreferences {
|
|
SpeculativePreferences {
|
|
mtp_draft_tokens: self.mtp_draft_tokens,
|
|
mtp_margin: self.mtp_margin,
|
|
glm_mtp: self.glm_mtp,
|
|
glm_mtp_timing: self.glm_mtp_timing,
|
|
dspark_enabled: self.dspark_enabled,
|
|
dspark_confidence_threshold: self.dspark_confidence_threshold,
|
|
dspark_strict: self.dspark_strict,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn runtime(&self) -> Result<RuntimePreferences, String> {
|
|
let cache = match (self.ssd_cache_experts, self.ssd_cache_gib) {
|
|
(None, None) => None,
|
|
(Some(experts), None) => Some(StreamingCacheBudget::Experts(
|
|
u32::try_from(experts).map_err(|_| "Saved SSD expert count is invalid.")?,
|
|
)),
|
|
(None, Some(gib)) => Some(StreamingCacheBudget::Gib(
|
|
u64::try_from(gib).map_err(|_| "Saved SSD cache budget is invalid.")?,
|
|
)),
|
|
(Some(_), Some(_)) => {
|
|
return Err("Saved SSD cache budget has both count and GiB values.".into());
|
|
}
|
|
};
|
|
Ok(RuntimePreferences {
|
|
execution: self.execution()?,
|
|
speculative: self.speculative(),
|
|
ssd: SsdPreferences {
|
|
enabled: self.ssd_streaming,
|
|
cold: self.ssd_streaming_cold,
|
|
cache,
|
|
full_layers: self
|
|
.ssd_full_layers
|
|
.map(u32::try_from)
|
|
.transpose()
|
|
.map_err(|_| "Saved SSD full-layer count is invalid.")?,
|
|
preload_experts: self
|
|
.ssd_preload_experts
|
|
.map(u32::try_from)
|
|
.transpose()
|
|
.map_err(|_| "Saved SSD preload count is invalid.")?,
|
|
},
|
|
steering: SteeringPreferences {
|
|
file: self.directional_steering_file.clone(),
|
|
ffn_scale: self.directional_steering_ffn,
|
|
attention_scale: self.directional_steering_attn,
|
|
},
|
|
diagnostics: DiagnosticPreferences {
|
|
simulated_used_memory_gib: self
|
|
.simulated_used_memory_gib
|
|
.map(u64::try_from)
|
|
.transpose()
|
|
.map_err(|_| "Saved simulated memory is invalid.")?,
|
|
expert_profile_path: self.expert_profile_path.clone(),
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(AsChangeset)]
|
|
#[diesel(table_name = preferences)]
|
|
struct PreferenceChanges<'a> {
|
|
selected_model: &'a str,
|
|
dspark_enabled: bool,
|
|
idle_timeout_minutes: i32,
|
|
context_tokens: i32,
|
|
max_generated_tokens: i32,
|
|
system_prompt: &'a str,
|
|
temperature: Option<f32>,
|
|
top_p: Option<f32>,
|
|
min_p: Option<f32>,
|
|
seed: Option<&'a str>,
|
|
reasoning_mode: &'a str,
|
|
cpu_threads: Option<i32>,
|
|
power_percent: Option<i32>,
|
|
prefill_chunk: Option<i32>,
|
|
quality: bool,
|
|
warm_weights: bool,
|
|
mtp_draft_tokens: i32,
|
|
mtp_margin: f32,
|
|
glm_mtp: bool,
|
|
glm_mtp_timing: bool,
|
|
dspark_confidence_threshold: Option<f32>,
|
|
dspark_strict: bool,
|
|
ssd_streaming: bool,
|
|
ssd_streaming_cold: bool,
|
|
ssd_cache_experts: Option<i64>,
|
|
ssd_cache_gib: Option<i64>,
|
|
ssd_full_layers: Option<i32>,
|
|
ssd_preload_experts: Option<i32>,
|
|
directional_steering_file: Option<&'a str>,
|
|
directional_steering_ffn: Option<f32>,
|
|
directional_steering_attn: Option<f32>,
|
|
simulated_used_memory_gib: Option<i64>,
|
|
expert_profile_path: Option<&'a str>,
|
|
endpoint_port: i32,
|
|
endpoint_enabled: bool,
|
|
endpoint_cors: bool,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Identifiable, Queryable, Selectable)]
|
|
#[diesel(table_name = projects)]
|
|
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
|
|
pub struct Project {
|
|
pub id: i32,
|
|
pub name: String,
|
|
pub path: String,
|
|
/// Sidebar state: hides this project's session rows.
|
|
pub collapsed: bool,
|
|
}
|
|
|
|
#[derive(Insertable)]
|
|
#[diesel(table_name = projects)]
|
|
struct NewProject<'a> {
|
|
name: &'a str,
|
|
path: &'a str,
|
|
}
|
|
|
|
#[derive(Associations, Clone, Debug, Identifiable, Queryable, Selectable)]
|
|
#[diesel(belongs_to(Project))]
|
|
#[diesel(table_name = sessions)]
|
|
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
|
|
pub struct Session {
|
|
pub id: i32,
|
|
pub project_id: i32,
|
|
pub title: String,
|
|
pub context_used: i32,
|
|
pub context_limit: i32,
|
|
pub last_tokens_per_second: Option<f32>,
|
|
/// Raw column value; read it through [`Session::state`].
|
|
state: String,
|
|
}
|
|
|
|
impl Session {
|
|
pub fn state(&self) -> SessionState {
|
|
SessionState::from_id(&self.state).unwrap_or_default()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub fn fixture(id: i32, project_id: i32, title: &str, state: SessionState) -> Self {
|
|
Self {
|
|
id,
|
|
project_id,
|
|
title: title.to_owned(),
|
|
context_used: 0,
|
|
context_limit: 0,
|
|
last_tokens_per_second: None,
|
|
state: state.as_id().to_owned(),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Where a session sits in the sidebar. These are lifecycle states, not flags:
|
|
/// a session is exactly one of them.
|
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
|
pub enum SessionState {
|
|
#[default]
|
|
Normal,
|
|
Pinned,
|
|
Archived,
|
|
}
|
|
|
|
impl SessionState {
|
|
pub fn as_id(self) -> &'static str {
|
|
match self {
|
|
Self::Normal => "normal",
|
|
Self::Pinned => "pinned",
|
|
Self::Archived => "archived",
|
|
}
|
|
}
|
|
|
|
pub fn from_id(id: &str) -> Option<Self> {
|
|
match id {
|
|
"normal" => Some(Self::Normal),
|
|
"pinned" => Some(Self::Pinned),
|
|
"archived" => Some(Self::Archived),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Sidebar grouping order: pinned, then ordinary, then archived.
|
|
pub fn rank(self) -> u8 {
|
|
match self {
|
|
Self::Pinned => 0,
|
|
Self::Normal => 1,
|
|
Self::Archived => 2,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Insertable)]
|
|
#[diesel(table_name = sessions)]
|
|
struct NewSession<'a> {
|
|
project_id: i32,
|
|
title: &'a str,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Identifiable, Queryable, Selectable)]
|
|
#[diesel(table_name = messages)]
|
|
#[diesel(check_for_backend(diesel::sqlite::Sqlite))]
|
|
pub struct StoredMessage {
|
|
pub id: i32,
|
|
pub session_id: i32,
|
|
pub user: bool,
|
|
pub tool: bool,
|
|
pub reasoning: Option<String>,
|
|
pub reasoning_complete: bool,
|
|
pub content: String,
|
|
}
|
|
|
|
#[derive(Insertable)]
|
|
#[diesel(table_name = messages)]
|
|
struct NewMessage<'a> {
|
|
session_id: i32,
|
|
user: bool,
|
|
tool: bool,
|
|
reasoning: Option<&'a str>,
|
|
reasoning_complete: bool,
|
|
content: &'a str,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct ProjectWithSessions {
|
|
pub project: Project,
|
|
pub sessions: Vec<Session>,
|
|
}
|
|
|
|
pub struct Database {
|
|
connection: SqliteConnection,
|
|
}
|
|
|
|
impl Database {
|
|
pub fn open(path: &Path) -> Result<Self, String> {
|
|
let parent = path
|
|
.parent()
|
|
.ok_or_else(|| "database path has no parent directory".to_owned())?;
|
|
fs::create_dir_all(parent).map_err(|error| error.to_string())?;
|
|
let url = path
|
|
.to_str()
|
|
.ok_or_else(|| "database path is not valid UTF-8".to_owned())?;
|
|
let mut connection = SqliteConnection::establish(url).map_err(|error| error.to_string())?;
|
|
connection
|
|
.run_pending_migrations(MIGRATIONS)
|
|
.map_err(|error| error.to_string())?;
|
|
Ok(Self { connection })
|
|
}
|
|
|
|
pub fn load_projects(&mut self) -> Result<Vec<ProjectWithSessions>, String> {
|
|
let project_rows = projects::table
|
|
.order(projects::id.asc())
|
|
.select(Project::as_select())
|
|
.load(&mut self.connection)
|
|
.map_err(|error| error.to_string())?;
|
|
let session_rows = sessions::table
|
|
.order(sessions::id.asc())
|
|
.select(Session::as_select())
|
|
.load::<Session>(&mut self.connection)
|
|
.map_err(|error| error.to_string())?;
|
|
let mut sessions_by_project: HashMap<i32, Vec<Session>> = HashMap::new();
|
|
for session in session_rows {
|
|
sessions_by_project
|
|
.entry(session.project_id)
|
|
.or_default()
|
|
.push(session);
|
|
}
|
|
// Pinned first, then ordinary, then archived; creation order within each
|
|
// group. A stable sort keeps the id ordering the query established.
|
|
for sessions in sessions_by_project.values_mut() {
|
|
sessions.sort_by_key(|session| session.state().rank());
|
|
}
|
|
|
|
Ok(project_rows
|
|
.into_iter()
|
|
.map(|project| ProjectWithSessions {
|
|
sessions: sessions_by_project.remove(&project.id).unwrap_or_default(),
|
|
project,
|
|
})
|
|
.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 set_sidebar_collapsed(&mut self, collapsed: bool) -> Result<(), String> {
|
|
diesel::update(preferences::table.find(1))
|
|
.set(preferences::sidebar_collapsed.eq(collapsed))
|
|
.execute(&mut self.connection)
|
|
.map(|_| ())
|
|
.map_err(|error| error.to_string())
|
|
}
|
|
|
|
pub fn set_sidebar_width(&mut self, width: i32) -> Result<(), String> {
|
|
diesel::update(preferences::table.find(1))
|
|
.set(preferences::sidebar_width.eq(width))
|
|
.execute(&mut self.connection)
|
|
.map(|_| ())
|
|
.map_err(|error| error.to_string())
|
|
}
|
|
|
|
pub fn set_last_project(&mut self, project_id: Option<i32>) -> Result<(), String> {
|
|
diesel::update(preferences::table.find(1))
|
|
.set(preferences::last_project_id.eq(project_id))
|
|
.execute(&mut self.connection)
|
|
.map(|_| ())
|
|
.map_err(|error| error.to_string())
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn update_preferences(
|
|
&mut self,
|
|
selected_model: &str,
|
|
idle_timeout_minutes: i32,
|
|
endpoint_port: i32,
|
|
endpoint_enabled: bool,
|
|
endpoint_cors: bool,
|
|
generation: &GenerationPreferences,
|
|
runtime: &RuntimePreferences,
|
|
) -> Result<AppPreferences, String> {
|
|
generation.validate()?;
|
|
let model = crate::model::ModelChoice::from_id(selected_model)
|
|
.ok_or_else(|| format!("Unsupported model: {selected_model}"))?;
|
|
runtime.validate(model)?;
|
|
if !(1..=65_535).contains(&endpoint_port) {
|
|
return Err("Endpoint port must be between 1 and 65535.".into());
|
|
}
|
|
let execution = &runtime.execution;
|
|
let speculative = &runtime.speculative;
|
|
let (ssd_cache_experts, ssd_cache_gib) = match runtime.ssd.cache {
|
|
None => (None, None),
|
|
Some(StreamingCacheBudget::Experts(experts)) => (Some(i64::from(experts)), None),
|
|
Some(StreamingCacheBudget::Gib(gib)) => (
|
|
None,
|
|
Some(i64::try_from(gib).map_err(|_| "SSD cache budget is too large.")?),
|
|
),
|
|
};
|
|
let seed = generation.seed.map(|seed| seed.to_string());
|
|
diesel::update(preferences::table.find(1))
|
|
.set(PreferenceChanges {
|
|
selected_model,
|
|
dspark_enabled: speculative.dspark_enabled,
|
|
idle_timeout_minutes,
|
|
context_tokens: generation.context_tokens,
|
|
max_generated_tokens: generation.max_generated_tokens,
|
|
system_prompt: &generation.system_prompt,
|
|
temperature: generation.temperature,
|
|
top_p: generation.top_p,
|
|
min_p: generation.min_p,
|
|
seed: seed.as_deref(),
|
|
reasoning_mode: generation.reasoning_mode.id(),
|
|
cpu_threads: execution.cpu_threads.map(|value| value as i32),
|
|
power_percent: execution.power_percent.map(i32::from),
|
|
prefill_chunk: execution.prefill_chunk.map(|value| value as i32),
|
|
quality: execution.quality,
|
|
warm_weights: execution.warm_weights,
|
|
mtp_draft_tokens: speculative.mtp_draft_tokens,
|
|
mtp_margin: speculative.mtp_margin,
|
|
glm_mtp: speculative.glm_mtp,
|
|
glm_mtp_timing: speculative.glm_mtp_timing,
|
|
dspark_confidence_threshold: speculative.dspark_confidence_threshold,
|
|
dspark_strict: speculative.dspark_strict,
|
|
ssd_streaming: runtime.ssd.enabled,
|
|
ssd_streaming_cold: runtime.ssd.cold,
|
|
ssd_cache_experts,
|
|
ssd_cache_gib,
|
|
ssd_full_layers: runtime.ssd.full_layers.map(|value| value as i32),
|
|
ssd_preload_experts: runtime.ssd.preload_experts.map(|value| value as i32),
|
|
directional_steering_file: runtime.steering.file.as_deref(),
|
|
directional_steering_ffn: runtime.steering.ffn_scale,
|
|
directional_steering_attn: runtime.steering.attention_scale,
|
|
simulated_used_memory_gib: runtime
|
|
.diagnostics
|
|
.simulated_used_memory_gib
|
|
.map(|value| value as i64),
|
|
expert_profile_path: runtime.diagnostics.expert_profile_path.as_deref(),
|
|
endpoint_port,
|
|
endpoint_enabled,
|
|
endpoint_cors,
|
|
})
|
|
.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> {
|
|
diesel::insert_into(projects::table)
|
|
.values(NewProject { name, path })
|
|
.returning(Project::as_returning())
|
|
.get_result(&mut self.connection)
|
|
.map_err(|error| error.to_string())
|
|
}
|
|
|
|
pub fn set_project_collapsed(
|
|
&mut self,
|
|
project_id: i32,
|
|
collapsed: bool,
|
|
) -> Result<(), String> {
|
|
diesel::update(projects::table.find(project_id))
|
|
.set(projects::collapsed.eq(collapsed))
|
|
.execute(&mut self.connection)
|
|
.map(|_| ())
|
|
.map_err(|error| error.to_string())
|
|
}
|
|
|
|
pub fn delete_project(&mut self, project_id: i32) -> Result<(), String> {
|
|
self.connection
|
|
.transaction(|connection| {
|
|
diesel::delete(
|
|
messages::table.filter(
|
|
messages::session_id.eq_any(
|
|
sessions::table
|
|
.filter(sessions::project_id.eq(project_id))
|
|
.select(sessions::id),
|
|
),
|
|
),
|
|
)
|
|
.execute(connection)?;
|
|
diesel::delete(sessions::table.filter(sessions::project_id.eq(project_id)))
|
|
.execute(connection)?;
|
|
diesel::delete(projects::table.find(project_id)).execute(connection)?;
|
|
Ok(())
|
|
})
|
|
.map_err(|error: diesel::result::Error| error.to_string())
|
|
}
|
|
|
|
pub fn create_session(&mut self, project_id: i32, title: &str) -> Result<Session, String> {
|
|
diesel::insert_into(sessions::table)
|
|
.values(NewSession { project_id, title })
|
|
.returning(Session::as_returning())
|
|
.get_result(&mut self.connection)
|
|
.map_err(|error| error.to_string())
|
|
}
|
|
|
|
pub fn rename_session(&mut self, session_id: i32, title: &str) -> Result<(), String> {
|
|
let title = title.trim();
|
|
if title.is_empty() {
|
|
return Err("Session title cannot be empty.".into());
|
|
}
|
|
diesel::update(sessions::table.find(session_id))
|
|
.set(sessions::title.eq(title))
|
|
.execute(&mut self.connection)
|
|
.map(|_| ())
|
|
.map_err(|error| error.to_string())
|
|
}
|
|
|
|
pub fn set_session_state(
|
|
&mut self,
|
|
session_id: i32,
|
|
state: SessionState,
|
|
) -> Result<(), String> {
|
|
diesel::update(sessions::table.find(session_id))
|
|
.set(sessions::state.eq(state.as_id()))
|
|
.execute(&mut self.connection)
|
|
.map(|_| ())
|
|
.map_err(|error| error.to_string())
|
|
}
|
|
|
|
pub fn delete_session(&mut self, session_id: i32) -> Result<(), String> {
|
|
self.connection
|
|
.transaction(|connection| {
|
|
diesel::delete(messages::table.filter(messages::session_id.eq(session_id)))
|
|
.execute(connection)?;
|
|
diesel::delete(sessions::table.find(session_id)).execute(connection)?;
|
|
Ok(())
|
|
})
|
|
.map_err(|error: diesel::result::Error| error.to_string())
|
|
}
|
|
|
|
pub fn load_messages(&mut self, session_id: i32) -> Result<Vec<StoredMessage>, String> {
|
|
messages::table
|
|
.filter(messages::session_id.eq(session_id))
|
|
.order(messages::id.asc())
|
|
.select(StoredMessage::as_select())
|
|
.load(&mut self.connection)
|
|
.map_err(|error| error.to_string())
|
|
}
|
|
|
|
pub fn update_session_context(
|
|
&mut self,
|
|
session_id: i32,
|
|
used: u32,
|
|
limit: u32,
|
|
tokens_per_second: Option<f32>,
|
|
) -> Result<(), String> {
|
|
let used = i32::try_from(used).map_err(|_| "Used context is too large to save")?;
|
|
let limit = i32::try_from(limit).map_err(|_| "Context limit is too large to save")?;
|
|
if tokens_per_second.is_some_and(|speed| !speed.is_finite() || speed < 0.0) {
|
|
return Err("Generation speed is invalid".into());
|
|
}
|
|
diesel::update(sessions::table.find(session_id))
|
|
.set((
|
|
sessions::context_used.eq(used),
|
|
sessions::context_limit.eq(limit),
|
|
sessions::last_tokens_per_second.eq(tokens_per_second),
|
|
))
|
|
.execute(&mut self.connection)
|
|
.map(|_| ())
|
|
.map_err(|error| error.to_string())
|
|
}
|
|
|
|
pub fn start_chat_turn(
|
|
&mut self,
|
|
session_id: i32,
|
|
prompt: &str,
|
|
reasoning: bool,
|
|
) -> Result<(StoredMessage, StoredMessage), String> {
|
|
self.connection
|
|
.transaction(|connection| {
|
|
let user = diesel::insert_into(messages::table)
|
|
.values(NewMessage {
|
|
session_id,
|
|
user: true,
|
|
tool: false,
|
|
reasoning: None,
|
|
reasoning_complete: true,
|
|
content: prompt,
|
|
})
|
|
.returning(StoredMessage::as_returning())
|
|
.get_result(connection)?;
|
|
let assistant = diesel::insert_into(messages::table)
|
|
.values(NewMessage {
|
|
session_id,
|
|
user: false,
|
|
tool: false,
|
|
reasoning: reasoning.then_some(""),
|
|
reasoning_complete: !reasoning,
|
|
content: "",
|
|
})
|
|
.returning(StoredMessage::as_returning())
|
|
.get_result(connection)?;
|
|
Ok((user, assistant))
|
|
})
|
|
.map_err(|error: diesel::result::Error| error.to_string())
|
|
}
|
|
|
|
pub fn continue_tool_turn(
|
|
&mut self,
|
|
session_id: i32,
|
|
result: &str,
|
|
reasoning: bool,
|
|
) -> Result<(StoredMessage, StoredMessage), String> {
|
|
self.connection
|
|
.transaction(|connection| {
|
|
let tool = diesel::insert_into(messages::table)
|
|
.values(NewMessage {
|
|
session_id,
|
|
user: false,
|
|
tool: true,
|
|
reasoning: None,
|
|
reasoning_complete: true,
|
|
content: result,
|
|
})
|
|
.returning(StoredMessage::as_returning())
|
|
.get_result(connection)?;
|
|
let assistant = diesel::insert_into(messages::table)
|
|
.values(NewMessage {
|
|
session_id,
|
|
user: false,
|
|
tool: false,
|
|
reasoning: reasoning.then_some(""),
|
|
reasoning_complete: !reasoning,
|
|
content: "",
|
|
})
|
|
.returning(StoredMessage::as_returning())
|
|
.get_result(connection)?;
|
|
Ok((tool, assistant))
|
|
})
|
|
.map_err(|error: diesel::result::Error| error.to_string())
|
|
}
|
|
|
|
pub fn update_message(
|
|
&mut self,
|
|
id: i32,
|
|
reasoning: Option<&str>,
|
|
reasoning_complete: bool,
|
|
content: &str,
|
|
) -> Result<(), String> {
|
|
diesel::update(messages::table.find(id))
|
|
.set((
|
|
messages::reasoning.eq(reasoning),
|
|
messages::reasoning_complete.eq(reasoning_complete),
|
|
messages::content.eq(content),
|
|
))
|
|
.execute(&mut self.connection)
|
|
.map(|_| ())
|
|
.map_err(|error| error.to_string())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
#[test]
|
|
fn migrations_and_crud_work() {
|
|
let id = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos();
|
|
let path = std::env::temp_dir().join(format!("ds4-server-{id}.sqlite3"));
|
|
let mut database = Database::open(&path).unwrap();
|
|
|
|
let preferences = database.load_preferences().unwrap();
|
|
assert_eq!(preferences.selected_model, "deepseek-v4-flash");
|
|
assert!(!preferences.dspark_enabled);
|
|
assert_eq!(preferences.idle_timeout_minutes, 10);
|
|
assert_eq!(preferences.endpoint_port, 4000);
|
|
assert!(preferences.endpoint_enabled);
|
|
assert!(!preferences.endpoint_cors);
|
|
let generation = GenerationPreferences::default();
|
|
let runtime = RuntimePreferences::default();
|
|
assert!(
|
|
database
|
|
.update_preferences(
|
|
"glm-5.2",
|
|
30,
|
|
4000,
|
|
true,
|
|
false,
|
|
&generation,
|
|
&RuntimePreferences {
|
|
speculative: SpeculativePreferences {
|
|
dspark_enabled: true,
|
|
..runtime.speculative.clone()
|
|
},
|
|
..runtime.clone()
|
|
}
|
|
)
|
|
.is_err()
|
|
);
|
|
assert!(
|
|
database
|
|
.update_preferences(
|
|
"deepseek-v4-flash",
|
|
0,
|
|
4000,
|
|
true,
|
|
false,
|
|
&generation,
|
|
&runtime,
|
|
)
|
|
.is_err()
|
|
);
|
|
let generation = GenerationPreferences {
|
|
top_p: Some(0.7),
|
|
seed: Some(u64::MAX),
|
|
reasoning_mode: ReasoningMode::Max,
|
|
..generation
|
|
};
|
|
let execution = ExecutionPreferences {
|
|
cpu_threads: Some(8),
|
|
quality: true,
|
|
..runtime.execution
|
|
};
|
|
let speculative = SpeculativePreferences {
|
|
glm_mtp: true,
|
|
glm_mtp_timing: true,
|
|
mtp_draft_tokens: 4,
|
|
mtp_margin: 2.5,
|
|
..runtime.speculative
|
|
};
|
|
let runtime = RuntimePreferences {
|
|
execution,
|
|
speculative,
|
|
ssd: SsdPreferences {
|
|
enabled: true,
|
|
cache: Some(StreamingCacheBudget::Gib(64)),
|
|
full_layers: Some(0),
|
|
preload_experts: Some(32),
|
|
..SsdPreferences::default()
|
|
},
|
|
diagnostics: DiagnosticPreferences {
|
|
simulated_used_memory_gib: Some(8),
|
|
expert_profile_path: Some("/tmp/experts.json".into()),
|
|
},
|
|
..RuntimePreferences::default()
|
|
};
|
|
database
|
|
.update_preferences("glm-5.2", 30, 4567, false, true, &generation, &runtime)
|
|
.unwrap();
|
|
let preferences = database.load_preferences().unwrap();
|
|
assert!(!preferences.endpoint_enabled);
|
|
assert!(preferences.endpoint_cors);
|
|
|
|
let project = database.create_project("DS4", "/tmp/ds4").unwrap();
|
|
let first = database
|
|
.create_session(project.id, "First session")
|
|
.unwrap();
|
|
database
|
|
.create_session(project.id, "Second session")
|
|
.unwrap();
|
|
database.delete_session(first.id).unwrap();
|
|
let loaded = database.load_projects().unwrap();
|
|
assert_eq!(loaded[0].sessions[0].title, "Second session");
|
|
assert_eq!(loaded[0].sessions[0].state(), SessionState::Normal);
|
|
|
|
let ordinary = loaded[0].sessions[0].id;
|
|
let pinned = database.create_session(project.id, "Pinned").unwrap();
|
|
let archived = database.create_session(project.id, "Archived").unwrap();
|
|
database
|
|
.set_session_state(pinned.id, SessionState::Pinned)
|
|
.unwrap();
|
|
database
|
|
.set_session_state(archived.id, SessionState::Archived)
|
|
.unwrap();
|
|
database.rename_session(pinned.id, " Renamed ").unwrap();
|
|
assert!(database.rename_session(pinned.id, " ").is_err());
|
|
let loaded = database.load_projects().unwrap();
|
|
assert_eq!(
|
|
loaded[0]
|
|
.sessions
|
|
.iter()
|
|
.map(|session| (session.id, session.state()))
|
|
.collect::<Vec<_>>(),
|
|
[
|
|
(pinned.id, SessionState::Pinned),
|
|
(ordinary, SessionState::Normal),
|
|
(archived.id, SessionState::Archived),
|
|
]
|
|
);
|
|
assert_eq!(loaded[0].sessions[0].title, "Renamed");
|
|
// Archiving a pinned session moves it out of the pinned group entirely.
|
|
database
|
|
.set_session_state(pinned.id, SessionState::Archived)
|
|
.unwrap();
|
|
let loaded = database.load_projects().unwrap();
|
|
assert_eq!(loaded[0].sessions[0].id, ordinary);
|
|
|
|
database.set_sidebar_collapsed(true).unwrap();
|
|
database.set_last_project(Some(project.id)).unwrap();
|
|
database.set_sidebar_width(320).unwrap();
|
|
assert!(!loaded[0].project.collapsed);
|
|
database.set_project_collapsed(project.id, true).unwrap();
|
|
assert!(database.load_projects().unwrap()[0].project.collapsed);
|
|
|
|
database.delete_project(project.id).unwrap();
|
|
assert!(database.load_projects().unwrap().is_empty());
|
|
drop(database);
|
|
|
|
let mut reopened = Database::open(&path).unwrap();
|
|
let preferences = reopened.load_preferences().unwrap();
|
|
assert!(preferences.sidebar_collapsed);
|
|
assert_eq!(preferences.last_project_id, Some(project.id));
|
|
assert_eq!(preferences.sidebar_width, 320);
|
|
assert_eq!(preferences.selected_model, "glm-5.2");
|
|
assert_eq!(preferences.idle_timeout_minutes, 30);
|
|
assert_eq!(preferences.endpoint_port, 4567);
|
|
assert_eq!(preferences.generation().unwrap(), generation);
|
|
assert_eq!(preferences.runtime().unwrap(), runtime);
|
|
drop(reopened);
|
|
fs::remove_file(path).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn chat_messages_survive_reopen_and_follow_session_deletion() {
|
|
let id = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos();
|
|
let path = std::env::temp_dir().join(format!("ds4-chat-{id}.sqlite3"));
|
|
let mut database = Database::open(&path).unwrap();
|
|
let project = database.create_project("DS4", "/tmp/ds4-chat").unwrap();
|
|
let session = database.create_session(project.id, "Chat").unwrap();
|
|
let (_, assistant) = database
|
|
.start_chat_turn(session.id, "Question", true)
|
|
.unwrap();
|
|
database
|
|
.update_message(assistant.id, Some("Reasoning"), true, "Answer")
|
|
.unwrap();
|
|
database
|
|
.continue_tool_turn(session.id, "Tool result", false)
|
|
.unwrap();
|
|
database
|
|
.update_session_context(session.id, 1_234, 65_536, Some(12.5))
|
|
.unwrap();
|
|
drop(database);
|
|
|
|
let mut reopened = Database::open(&path).unwrap();
|
|
let projects = reopened.load_projects().unwrap();
|
|
assert_eq!(projects[0].sessions[0].context_used, 1_234);
|
|
assert_eq!(projects[0].sessions[0].context_limit, 65_536);
|
|
assert_eq!(projects[0].sessions[0].last_tokens_per_second, Some(12.5));
|
|
let messages = reopened.load_messages(session.id).unwrap();
|
|
assert_eq!(messages.len(), 4);
|
|
assert!(messages[0].user);
|
|
assert!(!messages[0].tool);
|
|
assert_eq!(messages[0].content, "Question");
|
|
assert_eq!(messages[1].reasoning.as_deref(), Some("Reasoning"));
|
|
assert!(messages[1].reasoning_complete);
|
|
assert_eq!(messages[1].content, "Answer");
|
|
assert!(messages[2].tool);
|
|
assert_eq!(messages[2].content, "Tool result");
|
|
assert!(!messages[3].user);
|
|
assert!(!messages[3].tool);
|
|
reopened.delete_session(session.id).unwrap();
|
|
assert!(reopened.load_messages(session.id).unwrap().is_empty());
|
|
drop(reopened);
|
|
fs::remove_file(path).unwrap();
|
|
}
|
|
}
|