auto-git:
[change] src-tauri/Cargo.toml [change] src-tauri/capabilities/default.json [change] src-tauri/src/main.rs [change] src/desktop/desktopApi.js
This commit is contained in:
@@ -1,28 +1,273 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
use std::{collections::HashMap, sync::Mutex};
|
||||
use std::{
|
||||
fs,
|
||||
path::PathBuf,
|
||||
sync::Mutex,
|
||||
};
|
||||
|
||||
use serde_json::{json, Value};
|
||||
use tauri::State;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Map, Value};
|
||||
use tauri::{AppHandle, Emitter, Manager, State, WindowEvent};
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
type SettingsStore = Mutex<HashMap<String, Value>>;
|
||||
const DEFAULT_BACKEND_API_URL: &str = "http://127.0.0.1:8000";
|
||||
const DEFAULT_OLLAMA_API_URL: &str = "http://127.0.0.1:11434";
|
||||
const DEFAULT_EMBED_MODEL: &str = "nomic-embed-text:latest";
|
||||
const DEFAULT_TRANSCRIPTION_MODEL: &str = "base";
|
||||
const BGE_EMBED_MODEL: &str = "bge-m3:latest";
|
||||
const DEFAULT_UI_SCALE: f64 = 1.0;
|
||||
const MIN_UI_SCALE: f64 = 0.7;
|
||||
const MAX_UI_SCALE: f64 = 1.3;
|
||||
const SETTINGS_FILE_NAME: &str = "settings.json";
|
||||
const CHANGELOG_PAGE_SIZE: u64 = 50;
|
||||
|
||||
fn default_settings() -> HashMap<String, Value> {
|
||||
HashMap::from([
|
||||
("backendApiUrl".into(), json!("http://127.0.0.1:8000")),
|
||||
("ollamaApiUrl".into(), json!("http://127.0.0.1:11434")),
|
||||
("chatModel".into(), json!("llama3")),
|
||||
("visionModel".into(), json!("")),
|
||||
("embedModel".into(), json!("nomic-embed-text:latest")),
|
||||
("rerankModel".into(), json!("nomic-embed-text:latest")),
|
||||
("transcriptionModel".into(), json!("base")),
|
||||
("colorScheme".into(), json!("Default")),
|
||||
("uiScale".into(), json!(1)),
|
||||
("openDevToolsOnStartup".into(), json!(false)),
|
||||
("audioInputEnabled".into(), json!(true)),
|
||||
("audioInputDeviceId".into(), json!("")),
|
||||
("audioInputLanguage".into(), json!("")),
|
||||
])
|
||||
type SettingsMap = Map<String, Value>;
|
||||
|
||||
struct SettingsStore {
|
||||
path: PathBuf,
|
||||
settings: Mutex<SettingsMap>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PickPathsOptions {
|
||||
title: Option<String>,
|
||||
filters: Option<Vec<DialogFilter>>,
|
||||
multiple: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DialogFilter {
|
||||
name: Option<String>,
|
||||
extensions: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
fn default_settings() -> SettingsMap {
|
||||
let mut settings = SettingsMap::new();
|
||||
settings.insert("backendApiUrl".into(), json!(DEFAULT_BACKEND_API_URL));
|
||||
settings.insert("ollamaApiUrl".into(), json!(DEFAULT_OLLAMA_API_URL));
|
||||
settings.insert("chatModel".into(), json!("llama3"));
|
||||
settings.insert("visionModel".into(), json!(""));
|
||||
settings.insert("embedModel".into(), json!(DEFAULT_EMBED_MODEL));
|
||||
settings.insert("rerankModel".into(), json!(DEFAULT_EMBED_MODEL));
|
||||
settings.insert("transcriptionModel".into(), json!(DEFAULT_TRANSCRIPTION_MODEL));
|
||||
settings.insert("colorScheme".into(), json!("Default"));
|
||||
settings.insert("uiScale".into(), json!(DEFAULT_UI_SCALE));
|
||||
settings.insert("openDevToolsOnStartup".into(), json!(false));
|
||||
settings.insert("audioInputEnabled".into(), json!(true));
|
||||
settings.insert("audioInputDeviceId".into(), json!(""));
|
||||
settings.insert("audioInputLanguage".into(), json!(""));
|
||||
settings
|
||||
}
|
||||
|
||||
fn value_to_trimmed_string(value: Option<&Value>) -> String {
|
||||
match value {
|
||||
Some(Value::String(text)) => text.trim().to_string(),
|
||||
Some(Value::Number(number)) => number.to_string(),
|
||||
Some(Value::Bool(value)) => value.to_string(),
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn value_to_number(value: Option<&Value>) -> Option<f64> {
|
||||
match value {
|
||||
Some(Value::Number(number)) => number.as_f64(),
|
||||
Some(Value::String(text)) => text.trim().parse::<f64>().ok(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_ui_scale(value: Option<&Value>) -> f64 {
|
||||
let scale = value_to_number(value).unwrap_or(DEFAULT_UI_SCALE);
|
||||
let clamped = scale.clamp(MIN_UI_SCALE, MAX_UI_SCALE);
|
||||
(clamped * 100.0).round() / 100.0
|
||||
}
|
||||
|
||||
fn normalize_boolean_setting(value: Option<&Value>) -> bool {
|
||||
match value {
|
||||
Some(Value::String(text)) => match text.trim().to_lowercase().as_str() {
|
||||
"true" | "1" | "yes" | "on" => true,
|
||||
"false" | "0" | "no" | "off" | "" => false,
|
||||
_ => false,
|
||||
},
|
||||
Some(Value::Bool(value)) => *value,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_model_name(value: Option<&Value>, fallback: &str) -> String {
|
||||
let trimmed = value_to_trimmed_string(value);
|
||||
if trimmed.is_empty() {
|
||||
fallback.to_string()
|
||||
} else {
|
||||
trimmed
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_embed_model(value: Option<&Value>) -> String {
|
||||
let trimmed = value_to_trimmed_string(value);
|
||||
if trimmed.is_empty() {
|
||||
return DEFAULT_EMBED_MODEL.to_string();
|
||||
}
|
||||
|
||||
match trimmed.to_lowercase().as_str() {
|
||||
"bge" | "bge-m3" | BGE_EMBED_MODEL => BGE_EMBED_MODEL.to_string(),
|
||||
"nomic" | "nomic-embed-text" | DEFAULT_EMBED_MODEL => DEFAULT_EMBED_MODEL.to_string(),
|
||||
_ => trimmed,
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_ollama_url(value: &str) -> bool {
|
||||
tauri::Url::parse(value)
|
||||
.map(|parsed| {
|
||||
parsed.port() == Some(11434)
|
||||
|| parsed.path().trim_end_matches('/').eq_ignore_ascii_case("/api")
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn migrate_settings(source: Option<SettingsMap>) -> (SettingsMap, bool) {
|
||||
let source = source.unwrap_or_default();
|
||||
let mut next = default_settings();
|
||||
let mut migrated = false;
|
||||
|
||||
for (key, value) in source.iter() {
|
||||
next.insert(key.clone(), value.clone());
|
||||
}
|
||||
|
||||
if !source.contains_key("backendApiUrl") {
|
||||
if let Some(Value::String(ollama_api_url)) = source.get("ollamaApiUrl") {
|
||||
if looks_like_ollama_url(ollama_api_url) {
|
||||
next.insert("backendApiUrl".into(), json!(DEFAULT_BACKEND_API_URL));
|
||||
next.insert("ollamaApiUrl".into(), json!(ollama_api_url.trim()));
|
||||
} else {
|
||||
next.insert("backendApiUrl".into(), json!(ollama_api_url.trim()));
|
||||
next.insert("ollamaApiUrl".into(), json!(DEFAULT_OLLAMA_API_URL));
|
||||
}
|
||||
migrated = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !source.contains_key("openDevToolsOnStartup") {
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
if !source.contains_key("rerankModel") {
|
||||
let embed_model = next
|
||||
.get("embedModel")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!(DEFAULT_EMBED_MODEL));
|
||||
next.insert("rerankModel".into(), embed_model);
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
if !source.contains_key("visionModel") {
|
||||
let chat_model = value_to_trimmed_string(next.get("chatModel"));
|
||||
next.insert("visionModel".into(), json!(chat_model));
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
if !source.contains_key("transcriptionModel") {
|
||||
next.insert("transcriptionModel".into(), json!(DEFAULT_TRANSCRIPTION_MODEL));
|
||||
migrated = true;
|
||||
}
|
||||
|
||||
normalize_settings(&mut next);
|
||||
(next, migrated)
|
||||
}
|
||||
|
||||
fn normalize_settings(settings: &mut SettingsMap) {
|
||||
let backend_api_url = value_to_trimmed_string(settings.get("backendApiUrl"));
|
||||
let ollama_api_url = value_to_trimmed_string(settings.get("ollamaApiUrl"));
|
||||
let chat_model = normalize_model_name(settings.get("chatModel"), "");
|
||||
let vision_model = normalize_model_name(settings.get("visionModel"), "");
|
||||
let embed_model = normalize_embed_model(settings.get("embedModel"));
|
||||
let rerank_model = normalize_embed_model(settings.get("rerankModel"));
|
||||
let transcription_model =
|
||||
normalize_model_name(settings.get("transcriptionModel"), DEFAULT_TRANSCRIPTION_MODEL);
|
||||
let ui_scale = normalize_ui_scale(settings.get("uiScale"));
|
||||
let open_devtools_on_startup =
|
||||
normalize_boolean_setting(settings.get("openDevToolsOnStartup"));
|
||||
let audio_input_enabled = normalize_boolean_setting(settings.get("audioInputEnabled"));
|
||||
let audio_input_device_id = value_to_trimmed_string(settings.get("audioInputDeviceId"));
|
||||
let audio_input_language =
|
||||
value_to_trimmed_string(settings.get("audioInputLanguage")).to_lowercase();
|
||||
|
||||
settings.insert("backendApiUrl".into(), json!(backend_api_url));
|
||||
settings.insert("ollamaApiUrl".into(), json!(ollama_api_url));
|
||||
settings.insert("chatModel".into(), json!(chat_model));
|
||||
settings.insert("visionModel".into(), json!(vision_model));
|
||||
settings.insert("embedModel".into(), json!(embed_model));
|
||||
settings.insert("rerankModel".into(), json!(rerank_model));
|
||||
settings.insert("transcriptionModel".into(), json!(transcription_model));
|
||||
settings.insert("uiScale".into(), json!(ui_scale));
|
||||
settings.insert("openDevToolsOnStartup".into(), json!(open_devtools_on_startup));
|
||||
settings.insert("audioInputEnabled".into(), json!(audio_input_enabled));
|
||||
settings.insert("audioInputDeviceId".into(), json!(audio_input_device_id));
|
||||
settings.insert("audioInputLanguage".into(), json!(audio_input_language));
|
||||
}
|
||||
|
||||
fn settings_file_path(app: &AppHandle) -> Result<PathBuf, String> {
|
||||
if let Ok(path) = std::env::var("HEIMGEIST_SETTINGS_FILE") {
|
||||
let trimmed = path.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Ok(PathBuf::from(trimmed));
|
||||
}
|
||||
}
|
||||
|
||||
app.path()
|
||||
.app_config_dir()
|
||||
.map(|dir| dir.join(SETTINGS_FILE_NAME))
|
||||
.map_err(|error| format!("Could not resolve Tauri settings path: {error}"))
|
||||
}
|
||||
|
||||
fn read_settings_file(path: &PathBuf) -> Result<Option<SettingsMap>, String> {
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let data = fs::read_to_string(path)
|
||||
.map_err(|error| format!("Could not read settings from {}: {error}", path.display()))?;
|
||||
let value: Value = serde_json::from_str(&data)
|
||||
.map_err(|error| format!("Could not parse settings from {}: {error}", path.display()))?;
|
||||
|
||||
match value {
|
||||
Value::Object(settings) => Ok(Some(settings)),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_settings_file(path: &PathBuf, settings: &SettingsMap) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|error| {
|
||||
format!(
|
||||
"Could not create settings directory {}: {error}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
let data = serde_json::to_string_pretty(settings)
|
||||
.map_err(|error| format!("Could not serialize settings: {error}"))?;
|
||||
fs::write(path, format!("{data}\n"))
|
||||
.map_err(|error| format!("Could not write settings to {}: {error}", path.display()))
|
||||
}
|
||||
|
||||
fn load_settings_store(app: &AppHandle) -> Result<SettingsStore, String> {
|
||||
let path = settings_file_path(app)?;
|
||||
let raw_settings = read_settings_file(&path)?;
|
||||
let should_write = raw_settings.is_none();
|
||||
let (settings, migrated) = migrate_settings(raw_settings);
|
||||
|
||||
if should_write || migrated {
|
||||
write_settings_file(&path, &settings)?;
|
||||
}
|
||||
|
||||
Ok(SettingsStore {
|
||||
path,
|
||||
settings: Mutex::new(settings),
|
||||
})
|
||||
}
|
||||
|
||||
fn unavailable_update_status(message: &str) -> Value {
|
||||
@@ -37,77 +282,173 @@ fn unavailable_update_status(message: &str) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_changelog_page(page: Option<u64>) -> u64 {
|
||||
page.filter(|value| *value > 0).unwrap_or(1)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_settings(settings: State<'_, SettingsStore>) -> Result<Value, String> {
|
||||
let settings = settings
|
||||
fn get_settings(store: State<'_, SettingsStore>) -> Result<Value, String> {
|
||||
let settings = store
|
||||
.settings
|
||||
.lock()
|
||||
.map_err(|_| "Settings store is unavailable.".to_string())?;
|
||||
Ok(json!(settings.clone()))
|
||||
Ok(Value::Object(settings.clone()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_setting(
|
||||
key: String,
|
||||
value: Value,
|
||||
settings: State<'_, SettingsStore>,
|
||||
store: State<'_, SettingsStore>,
|
||||
) -> Result<bool, String> {
|
||||
let mut settings = settings
|
||||
let mut settings = store
|
||||
.settings
|
||||
.lock()
|
||||
.map_err(|_| "Settings store is unavailable.".to_string())?;
|
||||
settings.insert(key, value);
|
||||
normalize_settings(&mut settings);
|
||||
write_settings_file(&store.path, &settings)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn update_settings(
|
||||
settings: HashMap<String, Value>,
|
||||
settings: SettingsMap,
|
||||
store: State<'_, SettingsStore>,
|
||||
) -> Result<bool, String> {
|
||||
let mut store = store
|
||||
let mut current_settings = store
|
||||
.settings
|
||||
.lock()
|
||||
.map_err(|_| "Settings store is unavailable.".to_string())?;
|
||||
store.extend(settings);
|
||||
current_settings.extend(settings);
|
||||
normalize_settings(&mut current_settings);
|
||||
write_settings_file(&store.path, ¤t_settings)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_update_status() -> Value {
|
||||
unavailable_update_status("Tauri update checks are not implemented in this migration spike.")
|
||||
unavailable_update_status("Tauri update checks are not implemented in this migration phase.")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn check_for_updates() -> Value {
|
||||
unavailable_update_status("Tauri update checks are not implemented in this migration spike.")
|
||||
unavailable_update_status("Tauri update checks are not implemented in this migration phase.")
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_changelog_page(page: Option<u64>) -> Value {
|
||||
json!({
|
||||
"page": page.unwrap_or(1),
|
||||
"pageSize": 50,
|
||||
"page": normalize_changelog_page(page),
|
||||
"pageSize": CHANGELOG_PAGE_SIZE,
|
||||
"hasMore": false,
|
||||
"entries": []
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn pick_paths(_options: Option<Value>) -> Vec<String> {
|
||||
Vec::new()
|
||||
async fn pick_paths(app: AppHandle, options: Option<PickPathsOptions>) -> Result<Vec<String>, String> {
|
||||
let options = options.unwrap_or(PickPathsOptions {
|
||||
title: None,
|
||||
filters: None,
|
||||
multiple: None,
|
||||
});
|
||||
let mut dialog = app.dialog().file();
|
||||
|
||||
if let Some(title) = options.title.as_deref().map(str::trim).filter(|title| !title.is_empty()) {
|
||||
dialog = dialog.set_title(title);
|
||||
}
|
||||
|
||||
for filter in options.filters.unwrap_or_default() {
|
||||
let name = filter
|
||||
.name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or("Files")
|
||||
.to_string();
|
||||
let extensions: Vec<String> = filter
|
||||
.extensions
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|extension| extension.trim().trim_start_matches('.').to_string())
|
||||
.filter(|extension| !extension.is_empty())
|
||||
.collect();
|
||||
|
||||
if !extensions.is_empty() {
|
||||
let extension_refs: Vec<&str> = extensions.iter().map(String::as_str).collect();
|
||||
dialog = dialog.add_filter(name, &extension_refs);
|
||||
}
|
||||
}
|
||||
|
||||
let paths = if options.multiple.unwrap_or(true) {
|
||||
dialog.blocking_pick_files().unwrap_or_default()
|
||||
} else {
|
||||
dialog.blocking_pick_file().into_iter().collect()
|
||||
};
|
||||
|
||||
paths
|
||||
.into_iter()
|
||||
.map(|path| {
|
||||
path.into_path()
|
||||
.map(|path| path.to_string_lossy().into_owned())
|
||||
.map_err(|error| format!("Could not resolve selected file path: {error}"))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_path(_file_path: String) -> bool {
|
||||
false
|
||||
fn open_path(app: AppHandle, file_path: String) -> Result<bool, String> {
|
||||
let file_path = file_path.trim();
|
||||
if file_path.is_empty() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
match app.opener().open_path(file_path, None::<&str>) {
|
||||
Ok(_) => Ok(true),
|
||||
Err(error) => {
|
||||
eprintln!("Failed to open path {file_path}: {error}");
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_allowed_external_url(url: &str) -> bool {
|
||||
tauri::Url::parse(url)
|
||||
.map(|parsed| matches!(parsed.scheme(), "http" | "https" | "mailto" | "tel"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn open_external_link(_url: String) -> bool {
|
||||
false
|
||||
fn open_external_link(app: AppHandle, url: String) -> Result<bool, String> {
|
||||
let url = url.trim();
|
||||
if url.is_empty() || !is_allowed_external_url(url) {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
match app.opener().open_url(url, None::<&str>) {
|
||||
Ok(_) => Ok(true),
|
||||
Err(error) => {
|
||||
eprintln!("Failed to open URL {url}: {error}");
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
tauri::Builder::default()
|
||||
.manage(Mutex::new(default_settings()))
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.setup(|app| {
|
||||
let store = load_settings_store(app.handle()).map_err(std::io::Error::other)?;
|
||||
app.manage(store);
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
if matches!(event, WindowEvent::Focused(true)) {
|
||||
let _ = window.emit("window-focused", ());
|
||||
}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_settings,
|
||||
set_setting,
|
||||
|
||||
Reference in New Issue
Block a user