From b4dbcce87c664f618c8327540ff58822884a8a71 Mon Sep 17 00:00:00 2001 From: Can Ur Date: Sat, 25 Jul 2026 10:56:37 +0200 Subject: [PATCH 01/15] Backend stability, Search knowledge hubs, bulk capture --- .github/workflows/build.yml | 2 + package.json | 1 + src-tauri/Cargo.lock | 12 + src-tauri/Cargo.toml | 5 + src-tauri/src/lib.rs | 870 +++++++++++++++++- src/renderer/src/App.tsx | 150 +++ .../src/assets/styles/knowledge-shelves.css | 247 +++++ src/renderer/src/components/BrowserChrome.tsx | 11 + src/renderer/src/components/Dashboard.tsx | 216 ++++- src/renderer/src/tauri-aether.ts | 9 +- src/shared/aether.ts | 54 ++ 11 files changed, 1539 insertions(+), 38 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3ea65ce..0a6e77f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -71,6 +71,8 @@ jobs: - run: bun install --frozen-lockfile - run: bun run typecheck:web + # Unit tests are platform-independent, so one runner is enough. + - run: bun run test - run: bun run tauri build --bundles deb,appimage - run: scripts/normalize-deb-package.sh src-tauri/target/release/bundle/deb/*.deb diff --git a/package.json b/package.json index 80c8aca..6749f26 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", "typecheck:tauri": "cargo check --manifest-path src-tauri/Cargo.toml", "typecheck": "bun run typecheck:web && bun run typecheck:tauri", + "test": "cargo test --manifest-path src-tauri/Cargo.toml --lib", "version:bump": "bun scripts/bump-version.ts", "version:check": "bun scripts/bump-version.ts --check", "release": "bun scripts/release.ts", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index cecba8b..6553c1e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4320,9 +4320,21 @@ dependencies = [ "mio", "pin-project-lite", "socket2", + "tokio-macros", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tokio-rustls" version = "0.26.4" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e0508e4..4f57194 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -30,6 +30,11 @@ tokio = { version = "1", features = ["fs", "io-util", "sync", "time"] } url = "2" uuid = { version = "1", features = ["v4", "serde"] } +[dev-dependencies] +# The store-durability tests drive async fs helpers, which needs a runtime. Dev +# features are not unified into non-test builds, so this does not affect the app. +tokio = { version = "1", features = ["fs", "io-util", "macros", "rt"] } + [target.'cfg(target_os = "macos")'.dependencies] # Cargo unions features across target tables, so on macOS llama.cpp gets metal + sampler. llama-cpp-2 = { version = "0.1.146", default-features = false, features = ["metal"] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index eb83dfa..2167264 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -36,6 +36,11 @@ use tokio::task; use url::Url; const CHUNKS_TABLE: &str = "chunks"; +// Every on-disk store is written temp-then-rename, keeping the previous good copy +// beside it. See write_bytes_atomically / read_json_or_default. +const BACKUP_SUFFIX: &str = ".bak"; +const TEMP_WRITE_SUFFIX: &str = ".tmp"; +const LIBRARY_EXPORT_DIR: &str = "aether-backups"; #[cfg(desktop)] const SIDEBAR_WIDTH: f64 = 76.0; #[cfg(desktop)] @@ -286,6 +291,8 @@ struct DataPaths { air_exports_path: PathBuf, chunks_path: PathBuf, models_path: PathBuf, + // User-owned library snapshots (see aether_system_export_library). + exports_path: PathBuf, } #[derive(Clone)] @@ -932,6 +939,73 @@ struct CaptureCurrentPageInput { collection_id: String, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct SearchLibraryInput { + query: String, + // None searches every hub. + #[serde(default)] + collection_id: Option, + #[serde(default)] + limit: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct LibrarySearchHit { + capture_id: String, + collection_id: String, + collection_name: String, + title: String, + url: String, + host: String, + captured_at: String, + excerpt: String, + // 0-100 display score, not raw cosine distance. + score: f64, + chunk_matches: usize, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct LibrarySearchResult { + query: String, + hits: Vec, + // "semantic" when an embedding model ranked the results, "literal" when it fell + // back to substring matching so the UI can say which happened. + mode: String, + searched_chunks: usize, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CaptureUrlInput { + collection_id: String, + url: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CaptureUrlsInput { + collection_id: String, + urls: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct BulkCaptureFailure { + url: String, + reason: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct BulkCaptureResult { + captured: Vec, + collection_name: String, + failures: Vec, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct MoveCaptureInput { @@ -1006,6 +1080,17 @@ struct PartialUpdateSettings { auto_check: Option, } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct LibraryExportResult { + path: String, + exported_at: String, + files: Vec, + capture_count: usize, + chunk_count: usize, + byte_size: u64, +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct UpdateCheckResult { @@ -1175,7 +1260,13 @@ impl Default for IcebergData { #[serde(rename_all = "camelCase")] struct ChunkRecord { id: String, + // Vectors live in the binary sidecar, not in this JSON. Serializing 1024 f32s as + // decimal text cost ~12 KB per chunk and forced a full rewrite of every vector on + // every capture; `vector_slot` is the fixed-stride index into `chunks.vec`. + #[serde(skip)] vector: Vec, + #[serde(default)] + vector_slot: u64, text: String, collection_id: String, capture_id: String, @@ -1186,21 +1277,91 @@ struct ChunkRecord { chunk_index: usize, } +const VECTOR_STORE_VERSION: u8 = 2; +// Reclaim dead slots only once they dominate the file, so routine deletes stay O(1) +// instead of triggering a full rewrite each time. +const VECTOR_COMPACTION_MIN_SLOTS: u64 = 512; +const VECTOR_COMPACTION_DEAD_RATIO: f64 = 0.5; + #[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] struct VectorStoreData { version: u8, + // Embedding width, learned from the first vector stored. Fixes the sidecar stride. + #[serde(default)] + dim: usize, + // Monotonic slot allocator. Counts every slot ever handed out, including slots + // whose chunk was later deleted, so appends always land past live data. + #[serde(default)] + next_slot: u64, chunks: Vec, } impl Default for VectorStoreData { fn default() -> Self { Self { - version: 1, + version: VECTOR_STORE_VERSION, + dim: 0, + next_slot: 0, chunks: Vec::new(), } } } +impl VectorStoreData { + // Single place that hands out vector slots, so no caller can append a chunk whose + // slot does not match its position in the sidecar. + fn push_chunks(&mut self, records: impl IntoIterator) { + for mut record in records { + if record.vector.is_empty() { + continue; + } + if self.dim == 0 { + self.dim = record.vector.len(); + } + if record.vector.len() != self.dim { + eprintln!( + "aether: skipping chunk {} with {} dims (store is {})", + record.id, + record.vector.len(), + self.dim + ); + continue; + } + record.vector_slot = self.next_slot; + self.next_slot += 1; + self.chunks.push(record); + } + } +} + +// Vectors sit next to the metadata as `chunks.vec`. +fn vector_data_path(json_path: &Path) -> PathBuf { + json_path.with_extension("vec") +} + +// v1 stored vectors inline as JSON numbers. Kept only to migrate existing installs. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct LegacyChunkRecord { + id: String, + vector: Vec, + text: String, + collection_id: String, + capture_id: String, + title: String, + url: String, + app_id: String, + captured_at: String, + chunk_index: usize, +} + +#[derive(Deserialize)] +struct LegacyVectorStoreData { + #[serde(default)] + chunks: Vec, +} + struct CapturedPage { title: String, url: String, @@ -1229,6 +1390,7 @@ impl Backend { settings_path: app_data_dir.join("aether-settings").join("settings.json"), icebergs_path: app_data_dir.join("aether-icebergs").join("icebergs.json"), air_exports_path: app_data_dir.join("aether-air"), + exports_path: app_data_dir.join(LIBRARY_EXPORT_DIR), models_path, }, tabs: Mutex::new(TabState::new()), @@ -2767,10 +2929,13 @@ pub fn run() { aether_collections_delete, aether_collections_captures, aether_capture_current_page, + aether_capture_url, + aether_capture_urls, aether_capture_move, aether_capture_delete, aether_capture_suggest_hub, aether_search_collection, + aether_search_library, aether_semantic_trail_generate, aether_flow_graph, aether_air_prepare, @@ -2792,6 +2957,7 @@ pub fn run() { aether_system_update_models, aether_system_check_for_update, aether_system_download_models, + aether_system_export_library, aether_system_open_external_url, aether_layout_set_panel_collapsed, aether_layout_set_modal_overlay_open, @@ -3453,20 +3619,38 @@ async fn aether_collections_captures( Ok(captures) } +// Strict counterpart to normalize_url: capture must never silently turn a typo into +// a search-engine URL and then index the results page. Bare hosts are still +// accepted, since that is how people paste links. +fn capture_target_url(raw: &str) -> Cmd { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err("Enter a web address to capture.".to_string()); + } + let lowered = trimmed.to_ascii_lowercase(); + let candidate = if lowered.starts_with("http://") || lowered.starts_with("https://") { + trimmed.to_string() + } else if lowered.contains("://") { + return Err("Only http and https pages can be captured.".to_string()); + } else if !trimmed.contains(char::is_whitespace) && trimmed.contains('.') { + format!("https://{trimmed}") + } else { + return Err(format!("\"{trimmed}\" is not a web address.")); + }; + let parsed = + Url::parse(&candidate).map_err(|_| format!("\"{trimmed}\" is not a web address."))?; + if parsed.host_str().unwrap_or_default().is_empty() { + return Err(format!("\"{trimmed}\" is not a web address.")); + } + Ok(parsed.to_string()) +} + #[tauri::command] async fn aether_capture_current_page( app: AppHandle, state: State<'_, Backend>, input: CaptureCurrentPageInput, ) -> Cmd { - let settings = load_settings(&state.paths.settings_path).await?; - let mut library = load_library(&state.paths.library_path).await?; - let collection = library - .collections - .iter() - .find(|collection| collection.id == input.collection_id) - .cloned() - .ok_or_else(|| "Collection not found.".to_string())?; emit_capture_progress(&app, "Reading current page", None, None); let active_tab = { let tabs = lock_tabs(&state)?; @@ -3477,8 +3661,94 @@ async fn aether_capture_current_page( .cloned() .ok_or_else(|| "No active browser tab.".to_string())? }; + let app_id = active_tab.app_id.clone(); let captured = extract_readable_active_page(&state, &active_tab).await?; - emit_capture_progress(&app, "Chunking readable text", None, None); + capture_page_into_collection(&app, &state, &input.collection_id, captured, &app_id).await +} + +// Captures a page ÆTHER never had to load. This is what lets sources arrive from a +// pasted link, a dropped link, or a batch of open tabs instead of only from the +// active tab, so the library can grow without the app being the default browser. +#[tauri::command] +async fn aether_capture_url( + app: AppHandle, + state: State<'_, Backend>, + input: CaptureUrlInput, +) -> Cmd { + let target = capture_target_url(&input.url)?; + emit_capture_progress(&app, "Fetching page", None, None); + let captured = extract_readable_page(&state.client, &target).await?; + capture_page_into_collection(&app, &state, &input.collection_id, captured, "browser").await +} + +// Bulk sibling of aether_capture_url. One bad link in a batch must not discard the +// pages that did work, so failures are reported per URL instead of aborting. +#[tauri::command] +async fn aether_capture_urls( + app: AppHandle, + state: State<'_, Backend>, + input: CaptureUrlsInput, +) -> Cmd { + if input.urls.is_empty() { + return Err("No links to capture.".to_string()); + } + let collection = get_collection(&state.paths.library_path, &input.collection_id).await?; + + let total = input.urls.len(); + let mut captured = Vec::new(); + let mut failures = Vec::new(); + + for (index, raw_url) in input.urls.iter().enumerate() { + emit_capture_progress( + &app, + &format!("Capturing link {} of {total}", index + 1), + Some(index), + Some(total), + ); + let outcome = async { + let target = capture_target_url(raw_url)?; + let page = extract_readable_page(&state.client, &target).await?; + capture_page_into_collection(&app, &state, &input.collection_id, page, "browser").await + } + .await; + + match outcome { + Ok(result) => captured.push(result.capture), + Err(reason) => failures.push(BulkCaptureFailure { + url: raw_url.clone(), + reason, + }), + } + } + + emit_capture_progress(&app, "Finished capturing links", Some(total), Some(total)); + + Ok(BulkCaptureResult { + captured, + collection_name: collection.name, + failures, + }) +} + +// Shared tail of every capture path: chunk, embed, store vectors, update the +// library manifest. Kept in one place so the fetch-based captures cannot drift +// from the active-tab capture. +async fn capture_page_into_collection( + app: &AppHandle, + state: &State<'_, Backend>, + collection_id: &str, + captured: CapturedPage, + app_id: &str, +) -> Cmd { + let settings = load_settings(&state.paths.settings_path).await?; + let mut library = load_library(&state.paths.library_path).await?; + let collection = library + .collections + .iter() + .find(|collection| collection.id == collection_id) + .cloned() + .ok_or_else(|| "Collection not found.".to_string())?; + emit_capture_progress(app, "Chunking readable text", None, None); let captured_key = normalize_capture_url_key(&captured.url); if library.captures.iter().any(|capture| { capture.collection_id == collection.id @@ -3490,16 +3760,16 @@ async fn aether_capture_current_page( let (chunk_size, chunk_overlap) = capture_chunk_settings(&state.paths, &settings); let chunks = split_text(&captured.text, chunk_size, chunk_overlap); if chunks.is_empty() { - return Err("No readable text found on the current page.".to_string()); + return Err("No readable text found on that page.".to_string()); } emit_capture_progress( - &app, + app, &format!("Embedding {} chunks", chunks.len()), Some(0), Some(chunks.len()), ); let embeddings = local_embed_with_progress( - &state, + state, &settings, chunks.clone(), Some(EmbeddingProgress { @@ -3514,7 +3784,7 @@ async fn aether_capture_current_page( ); } emit_capture_progress( - &app, + app, "Saving capture", Some(chunks.len()), Some(chunks.len()), @@ -3533,13 +3803,13 @@ async fn aether_capture_current_page( capture_id: capture_id.clone(), title: captured.title.clone(), url: captured.url.clone(), - app_id: active_tab.app_id.clone(), + app_id: app_id.to_string(), captured_at: captured_at.clone(), chunk_index: index, }) .collect::>(); - with_vectors_mut(&state, |vectors| { + with_vectors_mut(state, |vectors| { vectors.chunks.extend(records.iter().cloned()); }) .await?; @@ -3549,7 +3819,7 @@ async fn aether_capture_current_page( collection_id: collection.id.clone(), title: captured.title, url: captured.url, - app_id: active_tab.app_id, + app_id: app_id.to_string(), captured_at, chunk_count: records.len(), metadata: None, @@ -4111,6 +4381,87 @@ async fn aether_system_download_models( system_status(&state).await } +// ÆTHER keeps no cloud copy, so a user-owned export is the only real backup. This +// snapshots every store into one timestamped folder the user can copy anywhere. +#[tauri::command] +async fn aether_system_export_library( + app: AppHandle, + state: State<'_, Backend>, +) -> Cmd { + let paths = &state.paths; + let exported_at = now(); + let folder_name = format!("aether-export-{}", exported_at.replace(':', "-")); + let target_dir = paths.exports_path.join(&folder_name); + tokio::fs::create_dir_all(&target_dir) + .await + .map_err(|error| error.to_string())?; + + let sources = [ + (&paths.library_path, "library.json"), + (&paths.chunks_path, "chunks.json"), + (&paths.settings_path, "settings.json"), + (&paths.icebergs_path, "icebergs.json"), + ]; + + let mut files = Vec::new(); + let mut byte_size = 0_u64; + for (source, name) in sources { + if !tokio::fs::try_exists(source).await.unwrap_or(false) { + continue; + } + let copied = tokio::fs::copy(source, target_dir.join(name)) + .await + .map_err(|error| format!("Could not export {name}: {error}"))?; + byte_size += copied; + files.push(name.to_string()); + } + + if files.is_empty() { + // Leaving an empty folder behind would look like a successful export. + let _ = tokio::fs::remove_dir(&target_dir).await; + return Err("There is nothing to export yet.".to_string()); + } + + let library = load_library(&paths.library_path).await.unwrap_or_default(); + let capture_count = library.captures.len(); + let chunk_count = library + .collections + .iter() + .map(|collection| collection.chunk_count) + .sum::(); + + let manifest = serde_json::json!({ + "app": "aether", + "appVersion": app.package_info().version.to_string(), + "exportedAt": exported_at, + "files": files, + "captureCount": capture_count, + "chunkCount": chunk_count, + "collectionCount": library.collections.len(), + }); + let manifest_raw = + serde_json::to_string_pretty(&manifest).map_err(|error| error.to_string())?; + tokio::fs::write(target_dir.join("manifest.json"), format!("{manifest_raw}\n")) + .await + .map_err(|error| error.to_string())?; + files.push("manifest.json".to_string()); + + // Opening the folder is the expected payoff of an explicit export click, but a + // desktop without a file manager should not fail the export. + if let Err(error) = app.opener().reveal_item_in_dir(&target_dir) { + eprintln!("aether: exported but could not reveal folder: {error}"); + } + + Ok(LibraryExportResult { + path: target_dir.display().to_string(), + exported_at, + files, + capture_count, + chunk_count, + byte_size, + }) +} + #[tauri::command(rename_all = "camelCase")] fn aether_system_open_external_url(app: AppHandle, url: String) -> Cmd<()> { let parsed = Url::parse(url.trim()).map_err(|_| "Invalid release URL.".to_string())?; @@ -4227,6 +4578,151 @@ async fn search_collection( .await } +// Library search groups by capture, not by chunk. The chunk-level results that +// power retrieval are the wrong shape for a person: eight hits from one long page +// reads as eight sources. One row per source, with its best-matching passage and a +// count of how many passages matched, is what someone scanning results wants. +async fn search_library( + state: &State<'_, Backend>, + input: SearchLibraryInput, +) -> Cmd { + let query = input.query.trim().to_string(); + if query.is_empty() { + return Ok(LibrarySearchResult { + query, + hits: Vec::new(), + mode: "semantic".to_string(), + searched_chunks: 0, + }); + } + + let library = load_library(&state.paths.library_path).await?; + let collection_names = library + .collections + .iter() + .map(|collection| (collection.id.clone(), collection.name.clone())) + .collect::>(); + if let Some(collection_id) = input.collection_id.as_deref() { + get_collection(&state.paths.library_path, collection_id).await?; + } + + let settings = load_settings(&state.paths.settings_path).await?; + let limit = input.limit.unwrap_or(20).clamp(1, 60); + + // Without an embedding model there is nothing to compare vectors against, so + // fall back to literal matching rather than failing. Search staying usable with + // no models installed is the difference between a browsable library and a + // library you can only guess at. + let query_vector = local_embed_query(state, &settings, query.clone()).await.ok(); + let mode = if query_vector.is_some() { + "semantic" + } else { + "literal" + }; + let needle = query.to_lowercase(); + + let (mut hits, searched_chunks) = with_vectors_read(state, |vectors| { + let scoped = vectors.chunks.iter().filter(|chunk| { + // map_or rather than is_none_or: the latter needs Rust 1.82 and this + // crate declares MSRV 1.77.2. + input + .collection_id + .as_deref() + .map_or(true, |id| chunk.collection_id == id) + }); + + let mut best: HashMap = HashMap::new(); + let mut examined = 0_usize; + + for chunk in scoped { + examined += 1; + let score = match query_vector.as_ref() { + Some(vector) => semantic_score_from_distance(cosine_distance(vector, &chunk.vector)), + None => literal_match_score(&needle, chunk), + }; + if score <= 0.0 { + continue; + } + + match best.get_mut(&chunk.capture_id) { + Some(existing) => { + existing.chunk_matches += 1; + if score > existing.score { + existing.score = score; + existing.excerpt = semantic_trail_excerpt(&chunk.text, 240); + } + } + None => { + best.insert( + chunk.capture_id.clone(), + LibrarySearchHit { + capture_id: chunk.capture_id.clone(), + collection_id: chunk.collection_id.clone(), + collection_name: collection_names + .get(&chunk.collection_id) + .cloned() + .unwrap_or_else(|| "Unknown hub".to_string()), + title: chunk.title.clone(), + url: chunk.url.clone(), + host: get_tab_host(&chunk.url), + captured_at: chunk.captured_at.clone(), + excerpt: semantic_trail_excerpt(&chunk.text, 240), + score, + chunk_matches: 1, + }, + ); + } + } + } + + (best.into_values().collect::>(), examined) + }) + .await?; + + hits.sort_by(|left, right| { + right + .score + .partial_cmp(&left.score) + .unwrap_or(Ordering::Equal) + .then_with(|| right.captured_at.cmp(&left.captured_at)) + }); + hits.truncate(limit); + + Ok(LibrarySearchResult { + query, + hits, + mode: mode.to_string(), + searched_chunks, + }) +} + +// Literal scoring for the no-embedding-model path. Title and host matches outrank +// body matches, because someone typing a remembered name wants that page first. +fn literal_match_score(needle: &str, chunk: &ChunkRecord) -> f64 { + if needle.is_empty() { + return 0.0; + } + let mut score = 0.0_f64; + if chunk.title.to_lowercase().contains(needle) { + score += 70.0; + } + if chunk.url.to_lowercase().contains(needle) { + score += 20.0; + } + if chunk.text.to_lowercase().contains(needle) { + score += 25.0; + } + score.min(100.0) +} + +#[tauri::command] +async fn aether_search_library( + state: State<'_, Backend>, + input: SearchLibraryInput, +) -> Cmd { + search_library(&state, input).await +} + #[derive(Clone)] struct SemanticTrailChunkCandidate { chunk: ChunkRecord, @@ -6006,41 +6502,137 @@ async fn with_vectors_mut( // Vector rows are large and machine-managed, so they are persisted as compact // JSON instead of the pretty format used for small user-editable stores. async fn save_vectors(path: &Path, data: &VectorStoreData) -> Cmd<()> { + let raw = serde_json::to_string(data).map_err(|error| error.to_string())?; + write_store_durably(path, raw.as_bytes()).await +} + +fn backup_path(path: &Path) -> PathBuf { + let mut name = path.file_name().unwrap_or_default().to_os_string(); + name.push(BACKUP_SUFFIX); + path.with_file_name(name) +} + +fn temp_write_path(path: &Path) -> PathBuf { + let mut name = path.file_name().unwrap_or_default().to_os_string(); + name.push(TEMP_WRITE_SUFFIX); + path.with_file_name(name) +} + +async fn ensure_parent_dir(path: &Path) -> Cmd<()> { if let Some(parent) = path.parent() { tokio::fs::create_dir_all(parent) .await .map_err(|error| error.to_string())?; } - let raw = serde_json::to_string(data).map_err(|error| error.to_string())?; - tokio::fs::write(path, raw) + Ok(()) +} + +// Writes bytes to a sibling temp file, fsyncs it, then renames it over the target. +// `rotate` additionally renames the previous good file to `.bak` first, so a +// crash can never leave the store both truncated and without a recoverable copy. +// +// Both renames are atomic within a directory, so the target is only ever the old +// complete file or the new complete file. The gap where the target is momentarily +// absent is covered by the backup, which `read_json_or_default` falls back to. +async fn write_bytes_atomically(path: &Path, bytes: &[u8], rotate: bool) -> Cmd<()> { + ensure_parent_dir(path).await?; + let temp = temp_write_path(path); + + // Scope the handle so it is flushed and closed before the rename. + { + let mut file = tokio::fs::File::create(&temp) + .await + .map_err(|error| error.to_string())?; + file.write_all(bytes) + .await + .map_err(|error| error.to_string())?; + // Without this the rename can land before the data does, which is exactly + // the truncated-store case this function exists to prevent. + file.sync_all().await.map_err(|error| error.to_string())?; + } + + if rotate && tokio::fs::try_exists(path).await.unwrap_or(false) { + let backup = backup_path(path); + if let Err(error) = tokio::fs::rename(path, &backup).await { + // A missing backup is recoverable; failing the whole save is not. + eprintln!( + "aether: could not rotate backup for {}: {error}", + path.display() + ); + } + } + + tokio::fs::rename(&temp, path) .await .map_err(|error| error.to_string()) } +async fn write_store_durably(path: &Path, bytes: &[u8]) -> Cmd<()> { + write_bytes_atomically(path, bytes, true).await +} + +// Quarantines an unparseable store instead of overwriting it. Losing a store to a +// bug is bad; silently replacing it with a default and destroying the evidence is +// worse, so the bad bytes are kept for manual recovery. +async fn quarantine_unreadable_store(path: &Path) { + if !tokio::fs::try_exists(path).await.unwrap_or(false) { + return; + } + let mut name = path.file_name().unwrap_or_default().to_os_string(); + name.push(format!(".corrupt-{}", now().replace(':', "-"))); + let target = path.with_file_name(name); + match tokio::fs::rename(path, &target).await { + Ok(()) => eprintln!( + "aether: kept unreadable store at {} for recovery", + target.display() + ), + Err(error) => eprintln!( + "aether: could not quarantine {}: {error}", + path.display() + ), + } +} + +// Load order: primary store, then `.bak`, then a fresh default. A parse failure on +// the primary is treated as corruption rather than as an error, so one bad file +// cannot make the app permanently unopenable. async fn read_json_or_default(path: &Path) -> Cmd where T: DeserializeOwned + Default + Serialize, { - match tokio::fs::read_to_string(path).await { - Ok(raw) => serde_json::from_str(&raw).map_err(|error| error.to_string()), - Err(_) => { - let data = T::default(); - save_json(path, &data).await?; - Ok(data) + if let Ok(raw) = tokio::fs::read_to_string(path).await { + match serde_json::from_str::(&raw) { + Ok(value) => return Ok(value), + Err(error) => eprintln!( + "aether: {} is unreadable ({error}); trying backup", + path.display() + ), + } + } + + let backup = backup_path(path); + if let Ok(raw) = tokio::fs::read_to_string(&backup).await { + if let Ok(value) = serde_json::from_str::(&raw) { + eprintln!("aether: recovered {} from backup", path.display()); + quarantine_unreadable_store(path).await; + // Restore without rotating: the backup we just read is the only good + // copy left, and rotating here would overwrite it with the bad file. + write_bytes_atomically(path, raw.as_bytes(), false).await?; + return Ok(value); } + eprintln!("aether: backup {} is also unreadable", backup.display()); } + + quarantine_unreadable_store(path).await; + let data = T::default(); + let raw = serde_json::to_string_pretty(&data).map_err(|error| error.to_string())?; + write_bytes_atomically(path, format!("{raw}\n").as_bytes(), false).await?; + Ok(data) } async fn save_json(path: &Path, data: &T) -> Cmd<()> { - if let Some(parent) = path.parent() { - tokio::fs::create_dir_all(parent) - .await - .map_err(|error| error.to_string())?; - } let raw = serde_json::to_string_pretty(data).map_err(|error| error.to_string())?; - tokio::fs::write(path, format!("{raw}\n")) - .await - .map_err(|error| error.to_string()) + write_store_durably(path, format!("{raw}\n").as_bytes()).await } async fn get_collection(path: &Path, collection_id: &str) -> Cmd { @@ -8703,6 +9295,218 @@ fn now() -> String { mod tests { use super::*; + fn block_on(future: F) -> F::Output { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("test runtime") + .block_on(future) + } + + struct TempDir(PathBuf); + + impl TempDir { + fn new() -> Self { + let path = env::temp_dir().join(format!("aether-store-test-{}", uuid())); + fs::create_dir_all(&path).expect("temp dir"); + Self(path) + } + + fn path(&self, name: &str) -> PathBuf { + self.0.join(name) + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + fn library_marked(marker: &str) -> LibraryData { + let mut data = LibraryData::default(); + data.migrated_realm_tables.push(marker.to_string()); + data + } + + fn marker_of(data: &LibraryData) -> Option<&str> { + data.migrated_realm_tables.first().map(String::as_str) + } + + fn corrupt_count(dir: &Path) -> usize { + fs::read_dir(dir) + .expect("read temp dir") + .filter_map(Result::ok) + .filter(|entry| entry.file_name().to_string_lossy().contains(".corrupt-")) + .count() + } + + #[test] + fn store_save_rotates_previous_version_into_backup() { + let dir = TempDir::new(); + let path = dir.path("library.json"); + + block_on(save_json(&path, &library_marked("first"))).expect("first save"); + block_on(save_json(&path, &library_marked("second"))).expect("second save"); + + let primary = block_on(read_json_or_default::(&path)).expect("read primary"); + assert_eq!(marker_of(&primary), Some("second")); + + let backup_raw = fs::read_to_string(backup_path(&path)).expect("backup exists"); + let backup: LibraryData = serde_json::from_str(&backup_raw).expect("backup parses"); + assert_eq!(marker_of(&backup), Some("first")); + } + + #[test] + fn store_save_leaves_no_temp_file_behind() { + let dir = TempDir::new(); + let path = dir.path("library.json"); + + block_on(save_json(&path, &library_marked("only"))).expect("save"); + + assert!( + !temp_write_path(&path).exists(), + "temp file should be renamed over the target, not left behind" + ); + } + + // The failure this guards against: a crash mid-save truncates the store and the + // user loses every capture. Recovery must be automatic and must not destroy the + // damaged file, so it stays available for manual inspection. + #[test] + fn store_read_recovers_from_backup_when_primary_is_truncated() { + let dir = TempDir::new(); + let path = dir.path("library.json"); + + block_on(save_json(&path, &library_marked("good"))).expect("first save"); + block_on(save_json(&path, &library_marked("newer"))).expect("second save"); + // Simulate a save that was interrupted partway through. + fs::write(&path, "{\"version\":1,\"collec").expect("truncate primary"); + + let recovered = block_on(read_json_or_default::(&path)).expect("recover"); + + assert_eq!(marker_of(&recovered), Some("good")); + assert_eq!(corrupt_count(&dir.0), 1, "damaged primary must be preserved"); + // The restored primary must itself be readable on the next launch. + let reread = block_on(read_json_or_default::(&path)).expect("reread"); + assert_eq!(marker_of(&reread), Some("good")); + } + + #[test] + fn store_read_falls_back_to_default_without_destroying_unreadable_files() { + let dir = TempDir::new(); + let path = dir.path("library.json"); + + block_on(save_json(&path, &library_marked("good"))).expect("save"); + block_on(save_json(&path, &library_marked("newer"))).expect("rotate"); + fs::write(&path, "not json").expect("corrupt primary"); + fs::write(backup_path(&path), "also not json").expect("corrupt backup"); + + let fresh = block_on(read_json_or_default::(&path)).expect("default"); + + assert_eq!(marker_of(&fresh), None); + assert_eq!(corrupt_count(&dir.0), 1, "unreadable primary must be kept"); + } + + #[test] + fn store_read_seeds_a_default_when_nothing_exists_yet() { + let dir = TempDir::new(); + let path = dir.path("nested").join("library.json"); + + let seeded = block_on(read_json_or_default::(&path)).expect("seed"); + + assert_eq!(marker_of(&seeded), None); + assert!(path.exists(), "first read should create the store"); + assert_eq!(corrupt_count(&dir.0), 0); + } + + fn chunk_for_search(capture_id: &str, title: &str, url: &str, text: &str) -> ChunkRecord { + ChunkRecord { + id: uuid(), + vector: vec![0.0; 4], + text: text.to_string(), + collection_id: "hub-1".to_string(), + capture_id: capture_id.to_string(), + title: title.to_string(), + url: url.to_string(), + app_id: "browser".to_string(), + captured_at: "2026-07-01T00:00:00Z".to_string(), + chunk_index: 0, + } + } + + // A remembered page name should outrank an incidental body mention, otherwise + // the page the user is picturing gets buried under passing references to it. + #[test] + fn literal_search_ranks_title_matches_above_body_matches() { + let titled = chunk_for_search("a", "Quantum mechanics", "https://example.com/a", "unrelated"); + let mentioned = + chunk_for_search("b", "Cooking basics", "https://example.com/b", "quantum mechanics"); + + let titled_score = literal_match_score("quantum mechanics", &titled); + let mentioned_score = literal_match_score("quantum mechanics", &mentioned); + + assert!(titled_score > mentioned_score); + assert!(mentioned_score > 0.0, "body matches should still be findable"); + } + + #[test] + fn literal_search_matches_hosts_and_ignores_misses() { + let chunk = chunk_for_search("a", "Augustus", "https://en.wikipedia.org/wiki/x", "body"); + + assert!(literal_match_score("wikipedia", &chunk) > 0.0); + assert_eq!(literal_match_score("nonexistent-term", &chunk), 0.0); + assert_eq!(literal_match_score("", &chunk), 0.0); + } + + #[test] + fn literal_search_is_case_insensitive_and_capped() { + let chunk = chunk_for_search("a", "Augustus", "https://augustus.example", "augustus"); + + // Title + url + body all match; the score must stay a valid percentage. + assert_eq!(literal_match_score("augustus", &chunk), 100.0); + } + + // The dangerous failure mode is silently capturing a search-results page for a + // typo, which would poison a hub with content the user never chose. + #[test] + fn capture_target_rejects_search_text_instead_of_guessing_a_url() { + assert!(capture_target_url("how do vaccines work").is_err()); + assert!(capture_target_url(" ").is_err()); + assert!(capture_target_url("notaurl").is_err()); + } + + #[test] + fn capture_target_accepts_bare_hosts_and_full_urls() { + assert_eq!( + capture_target_url("example.com/article"), + Ok("https://example.com/article".to_string()) + ); + assert_eq!( + capture_target_url(" https://en.wikipedia.org/wiki/Augustus "), + Ok("https://en.wikipedia.org/wiki/Augustus".to_string()) + ); + assert_eq!( + capture_target_url("http://example.com"), + Ok("http://example.com/".to_string()) + ); + } + + #[test] + fn capture_target_rejects_non_web_schemes() { + for raw in [ + "file:///etc/passwd", + "aether://dashboard", + "javascript:alert(1)", + "ftp://example.com/x", + ] { + assert!( + capture_target_url(raw).is_err(), + "{raw} should not be capturable" + ); + } + } + #[test] fn answer_citation_normalizer_removes_out_of_range_markers() { let answer = r#"The pelt was called "fitchet" [15]. It has another name [1, 16]."#; diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index c182129..9713afe 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -23,6 +23,9 @@ import { HubShortcutSummary, IcebergItem, IcebergResult, + LibraryExportResult, + LibrarySearchHit, + LibrarySearchResult, ModelDownloadChoice, ModelDownloadProgress, SearchEngineId, @@ -60,6 +63,7 @@ import { HAS_NATIVE_TAB_WEBVIEWS, IS_ANDROID } from './utils/platform' import { ChevronDown, ChevronUp, + HardDriveDownload, SearchIcon, Snowflake, Waves, @@ -409,6 +413,11 @@ function App(): React.JSX.Element { }) const [updateCheck, setUpdateCheck] = useState(null) const [updateChecking, setUpdateChecking] = useState(false) + const [capturingLink, setCapturingLink] = useState(false) + const [searching, setSearching] = useState(false) + const [searchResult, setSearchResult] = useState(null) + const [exportingLibrary, setExportingLibrary] = useState(false) + const [libraryExport, setLibraryExport] = useState(null) const [dashboardOpen, setDashboardOpen] = useState(true) const [workspaceMode, setWorkspaceMode] = useState<'dashboard' | 'crystallizer' | 'flow' | 'air'>( 'dashboard' @@ -715,6 +724,89 @@ function App(): React.JSX.Element { } }, []) + const searchLibrary = useCallback( + async (query: string, collectionId?: string): Promise => { + setSearching(true) + setNotice(null) + try { + setSearchResult(await window.aether.search.library({ query, collectionId })) + } catch (error) { + setNotice(getErrorMessage(error)) + } finally { + setSearching(false) + } + }, + [] + ) + + const clearSearch = useCallback((): void => setSearchResult(null), []) + + const captureLink = useCallback( + async (url: string, collectionId: string): Promise => { + setCapturingLink(true) + setNotice(null) + try { + const result = await window.aether.capture.url({ collectionId, url }) + await refreshCollections(collectionId) + setNotice(`Captured "${result.title}" into ${result.collectionName}.`) + } catch (error) { + setNotice(getErrorMessage(error)) + } finally { + setCapturingLink(false) + } + }, + [refreshCollections] + ) + + const captureOpenTabs = useCallback( + async (collectionId: string): Promise => { + // Start-page tabs have no web URL to fetch, so they are not offered. + const urls = tabs + .map((tab) => tab.url) + .filter((url) => url && url !== START_PAGE_URL && !url.startsWith('aether://')) + if (urls.length === 0) { + setNotice('No open tabs point at a web page yet.') + return + } + + setCapturingLink(true) + setNotice(null) + try { + const result = await window.aether.capture.urls({ collectionId, urls }) + await refreshCollections(collectionId) + const saved = `Captured ${result.captured.length} of ${urls.length} tabs into ${result.collectionName}.` + setNotice( + result.failures.length > 0 + ? `${saved} Skipped: ${result.failures.map((item) => item.reason).join(' ')}` + : saved + ) + } catch (error) { + setNotice(getErrorMessage(error)) + } finally { + setCapturingLink(false) + } + }, + [refreshCollections, tabs] + ) + + const exportLibrary = useCallback(async (): Promise => { + setExportingLibrary(true) + setNotice(null) + try { + const result = await window.aether.system.exportLibrary() + setLibraryExport(result) + setNotice( + `Exported ${result.captureCount} sources (${formatExportSize(result.byteSize)}) to ${ + result.path + }` + ) + } catch (error) { + setNotice(getErrorMessage(error)) + } finally { + setExportingLibrary(false) + } + }, []) + useEffect(() => { if (!settings.updates.autoCheck || autoUpdateCheckStartedRef.current) return undefined const timer = window.setTimeout(() => { @@ -1465,6 +1557,11 @@ function App(): React.JSX.Element { setDashboardOpen(false) } + async function openSearchHit(hit: LibrarySearchHit): Promise { + await createTab({ url: hit.url }) + setDashboardOpen(false) + } + async function openFlowSource(node: FlowGraphNode): Promise { if (!node.url) return await createTab({ url: node.url }) @@ -1985,7 +2082,18 @@ function App(): React.JSX.Element { const dashboardNode = ( tab.url && !tab.url.startsWith('aether://')).length + } collections={collections} deleteCapture={deleteCapture} deleteSavedIceberg={deleteSavedIceberg} @@ -2049,12 +2157,15 @@ function App(): React.JSX.Element { {settingsOpen && ( checkForUpdates()} onOpenUpdateRelease={openUpdateRelease} onUpdateAutoCheck={updateAutoCheck} @@ -2520,6 +2631,13 @@ function FindBar({ ) } +function formatExportSize(bytes: number): string { + if (bytes >= 1_000_000_000) return `${(bytes / 1_000_000_000).toFixed(1)} GB` + if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB` + if (bytes >= 1_000) return `${Math.round(bytes / 1_000)} KB` + return `${bytes} B` +} + function formatSettingsDate(value?: string): string { if (!value) return 'Never' const date = new Date(value) @@ -2541,6 +2659,8 @@ function updateStatusLabel(updateCheck: UpdateCheckResult | null): string { function SettingsModal({ busy, + exportingLibrary, + libraryExport, settings, updateCheck, updateChecking, @@ -2548,11 +2668,14 @@ function SettingsModal({ onCheckForUpdates, onDefaultSearchEngineChange, onDeveloperModeChange, + onExportLibrary, onOpenUpdateRelease, onUpdateAutoCheck, onOpenModelSetup }: { busy: string | null + exportingLibrary: boolean + libraryExport: LibraryExportResult | null settings: AppSettings updateCheck: UpdateCheckResult | null updateChecking: boolean @@ -2560,6 +2683,7 @@ function SettingsModal({ onCheckForUpdates: () => Promise onDefaultSearchEngineChange: (value: SearchEngineId) => Promise onDeveloperModeChange: (value: boolean) => Promise + onExportLibrary: () => Promise onOpenUpdateRelease: () => Promise onUpdateAutoCheck: (value: boolean) => Promise onOpenModelSetup: () => Promise @@ -2689,6 +2813,32 @@ function SettingsModal({ +
+
+ + + Library backup + + {libraryExport + ? `Last export: ${libraryExport.captureCount} sources, ${formatExportSize( + libraryExport.byteSize + )} — ${libraryExport.path}` + : 'ÆTHER keeps no cloud copy. Save a snapshot of your hubs, sources, and maps.'} + + +
+ +
+
diff --git a/src/renderer/src/assets/styles/knowledge-shelves.css b/src/renderer/src/assets/styles/knowledge-shelves.css index 7d27aac..2991814 100644 --- a/src/renderer/src/assets/styles/knowledge-shelves.css +++ b/src/renderer/src/assets/styles/knowledge-shelves.css @@ -295,6 +295,253 @@ line-height: 1.25; } +/* Library search. First thing under the section title, because finding a source you + already captured is the most common reason to open the dashboard. */ +.library-search { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + width: 100%; + margin-bottom: 10px; +} + +.library-search input[type='search'], +.library-search select, +.library-search button { + height: 38px; + box-sizing: border-box; + line-height: 1.2; + font: inherit; + font-size: 13px; +} + +.library-search input[type='search'] { + flex: 1 1 280px; + min-width: 0; + padding: 8px 14px; + border: 1px solid rgba(75, 163, 206, 0.32); + border-radius: 11px; + background: rgba(255, 255, 255, 0.96); + color: var(--text-strong, #12304a); +} + +.library-search input[type='search']::placeholder { + color: rgba(58, 105, 138, 0.6); +} + +.library-search select { + flex: 0 0 auto; + max-width: 170px; + padding: 8px 10px; + border: 1px solid rgba(75, 163, 206, 0.3); + border-radius: 11px; + background: rgba(255, 255, 255, 0.94); +} + +.library-search button { + flex: 0 0 auto; + padding: 8px 16px; + border: 1px solid rgba(75, 163, 206, 0.44); + border-radius: 11px; + background: linear-gradient(145deg, rgba(238, 250, 255, 0.98), rgba(197, 232, 250, 0.94)); + font-weight: 660; + color: #0e4767; + cursor: pointer; +} + +.library-search button:hover:not(:disabled) { + border-color: rgba(75, 163, 206, 0.66); + background: linear-gradient(145deg, rgba(255, 255, 255, 1), rgba(184, 227, 249, 0.98)); +} + +.library-search button:disabled, +.library-search input:disabled, +.library-search select:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.library-search-results { + flex: 1 0 100%; + margin-top: 2px; +} + +.library-search-summary { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 8px; + margin: 0 0 8px; + font-size: 12px; + font-weight: 680; + color: rgba(30, 79, 112, 0.9); +} + +.library-search-mode { + padding: 2px 8px; + border-radius: 999px; + background: rgba(246, 224, 174, 0.44); + font-size: 10.5px; + font-weight: 660; + color: #6d4c15; +} + +.library-search-results ul { + display: flex; + flex-direction: column; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; + /* Long result sets scroll inside the block instead of pushing the hubs off-screen. */ + max-height: 340px; + overflow-y: auto; +} + +.library-search-results button { + display: grid; + gap: 3px; + width: 100%; + height: auto; + padding: 10px 12px; + border: 1px solid rgba(75, 163, 206, 0.24); + border-radius: 12px; + background: linear-gradient(145deg, rgba(255, 255, 255, 0.96), rgba(236, 247, 253, 0.8)); + text-align: left; + font-weight: 500; + cursor: pointer; +} + +.library-search-results button:hover { + border-color: rgba(75, 163, 206, 0.5); + background: linear-gradient(145deg, rgba(255, 255, 255, 1), rgba(223, 243, 253, 0.94)); +} + +.library-search-hit-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; +} + +.library-search-hit-head strong { + font-size: 13px; + color: #12304a; +} + +.library-search-score { + flex: 0 0 auto; + font-size: 11px; + font-weight: 700; + color: rgba(30, 110, 158, 0.92); +} + +.library-search-hit-meta { + font-size: 11px; + font-weight: 620; + color: rgba(58, 105, 138, 0.78); +} + +.library-search-excerpt { + font-size: 11.5px; + line-height: 1.45; + color: rgba(40, 74, 100, 0.82); +} + +/* Capture-by-link row. Sits directly under the section title so adding a source is + reachable without opening a page first (paste, or drop a tab / external link on a + hub below). */ +.hub-link-capture { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + width: 100%; + margin-bottom: 12px; + padding: 10px 12px; + border: 1px solid rgba(75, 163, 206, 0.26); + border-radius: 14px; + background: linear-gradient(145deg, rgba(255, 255, 255, 0.94), rgba(233, 246, 253, 0.72)); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9); +} + +/* Input, select and button each compute a different intrinsic height from their + own font metrics, which left the row visibly ragged. Pin one height so the three + read as a single control group. */ +.hub-link-capture input[type='text'], +.hub-link-capture select, +.hub-link-capture button { + height: 38px; + box-sizing: border-box; + line-height: 1.2; +} + +.hub-link-capture input[type='text'] { + flex: 1 1 260px; + min-width: 0; + padding: 8px 12px; + border: 1px solid rgba(75, 163, 206, 0.3); + border-radius: 10px; + background: rgba(255, 255, 255, 0.92); + font: inherit; + font-size: 13px; + color: var(--text-strong, #12304a); +} + +.hub-link-capture input[type='text']::placeholder { + color: rgba(58, 105, 138, 0.62); +} + +.hub-link-capture select { + flex: 0 0 auto; + max-width: 180px; + padding: 8px 10px; + border: 1px solid rgba(75, 163, 206, 0.3); + border-radius: 10px; + background: rgba(255, 255, 255, 0.92); + font: inherit; + font-size: 13px; +} + +.hub-link-capture button { + flex: 0 0 auto; + padding: 8px 14px; + border: 1px solid rgba(75, 163, 206, 0.4); + border-radius: 10px; + background: linear-gradient(145deg, rgba(238, 250, 255, 0.98), rgba(203, 235, 250, 0.92)); + font: inherit; + font-size: 13px; + font-weight: 650; + color: #10496b; + cursor: pointer; +} + +.hub-link-capture button:hover:not(:disabled) { + border-color: rgba(75, 163, 206, 0.62); + background: linear-gradient(145deg, rgba(255, 255, 255, 1), rgba(190, 230, 249, 0.96)); +} + +.hub-link-capture button:disabled, +.hub-link-capture input:disabled, +.hub-link-capture select:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.hub-link-capture small { + flex: 1 0 100%; + font-size: 11px; + font-weight: 600; + color: rgba(58, 105, 138, 0.72); +} + +@media (prefers-reduced-motion: reduce) { + .hub-link-capture button { + transition: none; + } +} + /* A single comfortably-capped column. Collapsed and expanded hubs share the same width, so opening one grows it in place instead of hopping to a full-width row. */ .knowledge-band .collection-list { diff --git a/src/renderer/src/components/BrowserChrome.tsx b/src/renderer/src/components/BrowserChrome.tsx index b5c9e4e..2db6500 100644 --- a/src/renderer/src/components/BrowserChrome.tsx +++ b/src/renderer/src/components/BrowserChrome.tsx @@ -212,6 +212,17 @@ export function BrowserChrome({ tab.isActive && !dashboardOpen ? 'active' : '' }`} key={tab.id} + // Dragging a tab onto a Knowledge Hub on the dashboard captures it. + // The private MIME type is what lets the drop target tell this apart + // from ÆTHER's internal reorder drags. + draggable={Boolean(tab.url)} + onDragStart={(event) => { + if (!tab.url) return + event.dataTransfer.effectAllowed = 'copy' + event.dataTransfer.setData('application/x-aether-tab', tab.url) + event.dataTransfer.setData('text/uri-list', tab.url) + event.dataTransfer.setData('text/plain', tab.url) + }} onClick={() => onSelectTab(tab.id)} onContextMenu={(event) => openTabMenu(event, tab.id)} style={getTabStyle(tab)} diff --git a/src/renderer/src/components/Dashboard.tsx b/src/renderer/src/components/Dashboard.tsx index cba16a6..9416cb9 100644 --- a/src/renderer/src/components/Dashboard.tsx +++ b/src/renderer/src/components/Dashboard.tsx @@ -25,6 +25,8 @@ import { CaptureSummary, CollectionSummary, HubShortcutSummary, + LibrarySearchHit, + LibrarySearchResult, SavedIcebergSummary } from '../../../shared/aether' import { CollectionIcon } from '../utils/collection-icons' @@ -46,9 +48,42 @@ type CollectionDialogState = | { mode: 'delete'; collection: CollectionSummary } | null +// A drag carries a capturable link when it is not one of ÆTHER's own internal +// reorder/move drags. Internal drags are identified by their private MIME types, +// which is the only thing readable during dragover. +function isLinkDrag(types: readonly string[]): boolean { + if (types.includes('application/x-aether-capture')) return false + if (types.includes('application/x-aether-collection')) return false + return types.includes('application/x-aether-tab') || types.includes('text/uri-list') +} + +function readDroppedLink(transfer: DataTransfer): string { + const tabUrl = transfer.getData('application/x-aether-tab') + if (tabUrl) return tabUrl + // text/uri-list may hold several lines; the first non-comment line is the link. + const uriList = transfer.getData('text/uri-list') + if (uriList) { + const first = uriList + .split(/\r?\n/) + .map((line) => line.trim()) + .find((line) => line.length > 0 && !line.startsWith('#')) + if (first) return first + } + return transfer.getData('text/plain').trim() +} + type DashboardProps = { busy: string | null + searchResult: LibrarySearchResult | null + searching: boolean + searchLibrary: (query: string, collectionId?: string) => Promise + clearSearch: () => void + openSearchHit: (hit: LibrarySearchHit) => Promise capturesByCollection: Record + capturingLink: boolean + captureLink: (url: string, collectionId: string) => Promise + captureOpenTabs: (collectionId: string) => Promise + openTabCount: number collections: CollectionSummary[] deleteCapture: (captureId: string) => Promise deleteSavedIceberg: (id: string) => Promise @@ -70,7 +105,16 @@ type DashboardProps = { export function Dashboard({ busy, + searchResult, + searching, + searchLibrary, + clearSearch, + openSearchHit, capturesByCollection, + capturingLink, + captureLink, + captureOpenTabs, + openTabCount, collections, deleteCapture, deleteSavedIceberg, @@ -102,6 +146,10 @@ export function Dashboard({ // a synchronously-correct value mid-drag, the way the cross-hub move reads dataTransfer. const captureDragRef = useRef<{ id: string; from: string } | null>(null) const [dragOverCollectionId, setDragOverCollectionId] = useState('') + const [linkDraft, setLinkDraft] = useState('') + const [linkTargetId, setLinkTargetId] = useState(selectedCollectionId) + const [searchDraft, setSearchDraft] = useState('') + const [searchScopeId, setSearchScopeId] = useState('') const aetherMarkSrc = new URL('aether-mark.svg', window.location.href).toString() const wavyLinesSrc = new URL('wavy-lines.svg', window.location.href).toString() @@ -394,10 +442,155 @@ export function Dashboard({ onClick={() => openCollectionDialog({ mode: 'create' })} type="button" > - New Collection + Add
+ {collections.length > 0 && ( +
{ + event.preventDefault() + const query = searchDraft.trim() + if (!query) return + void searchLibrary(query, searchScopeId || undefined) + }} + role="search" + > + { + setSearchDraft(event.target.value) + if (!event.target.value.trim()) clearSearch() + }} + placeholder="Search your captured sources…" + type="search" + value={searchDraft} + /> + + + {searchResult && ( + + )} + + {searchResult && ( +
+

+ {searchResult.hits.length === 0 + ? `No sources match "${searchResult.query}".` + : `${searchResult.hits.length} source${ + searchResult.hits.length !== 1 ? 's' : '' + } for "${searchResult.query}"`} + {/* Say when ranking was literal, so a weak result set is not + mistaken for an empty library. */} + {searchResult.mode === 'literal' && ( + + name matching only — install an embedding model for meaning-based + search + + )} +

+
    + {searchResult.hits.map((hit) => ( +
  • + +
  • + ))} +
+
+ )} +
+ )} + + {collections.length > 0 && ( +
{ + event.preventDefault() + const url = linkDraft.trim() + const target = linkTargetId || collections[0].id + if (!url) return + void captureLink(url, target).then(() => setLinkDraft('')) + }} + > + setLinkDraft(event.target.value)} + placeholder="Paste a link to capture without opening it…" + type="text" + value={linkDraft} + /> + + + {openTabCount > 0 && ( + + )} + Or drag a tab or link onto a hub below. +
+ )} + {collections.length === 0 ? (

No collections yet

@@ -428,14 +621,16 @@ export function Dashboard({ }`} draggable onDragEnter={(event) => { - if (!canDropCapture && !canDropCollection) return + const canDropLink = isLinkDrag(event.dataTransfer.types) + if (!canDropCapture && !canDropCollection && !canDropLink) return event.preventDefault() setDragOverCollectionId(collection.id) }} onDragOver={(event) => { - if (!canDropCapture && !canDropCollection) return + const canDropLink = isLinkDrag(event.dataTransfer.types) + if (!canDropCapture && !canDropCollection && !canDropLink) return event.preventDefault() - event.dataTransfer.dropEffect = 'move' + event.dataTransfer.dropEffect = canDropLink ? 'copy' : 'move' }} onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) { @@ -456,6 +651,19 @@ export function Dashboard({ onDrop={async (event) => { event.preventDefault() setDragOverCollectionId('') + + // A dragged tab chip or an external link becomes a new capture. + // Checked before the internal branches so a link drop is never + // mistaken for a reorder. + if (isLinkDrag(event.dataTransfer.types)) { + const url = readDroppedLink(event.dataTransfer) + if (url) { + setOpenCollectionId(collection.id) + await captureLink(url, collection.id) + } + return + } + const collectionId = event.dataTransfer.getData( 'application/x-aether-collection' ) diff --git a/src/renderer/src/tauri-aether.ts b/src/renderer/src/tauri-aether.ts index f870d9d..f4c5b5a 100644 --- a/src/renderer/src/tauri-aether.ts +++ b/src/renderer/src/tauri-aether.ts @@ -11,6 +11,7 @@ import { AppSettings, AppSummary, BrowserTabSummary, + BulkCaptureResult, CaptureHubSuggestion, CaptureProgress, CaptureResult, @@ -22,6 +23,8 @@ import { FlowGraphResult, HubShortcutSummary, IcebergResult, + LibraryExportResult, + LibrarySearchResult, MOBILE_TAB_SCROLL_EVENT, ModelDownloadProgress, NativeTabEvent, @@ -83,12 +86,15 @@ if (isTauri) { }, capture: { currentPage: (input) => call('aether_capture_current_page', { input }), + url: (input) => call('aether_capture_url', { input }), + urls: (input) => call('aether_capture_urls', { input }), move: (input) => call('aether_capture_move', { input }), delete: (captureId) => call('aether_capture_delete', { captureId }), suggestHub: () => call('aether_capture_suggest_hub') }, search: { - collection: (input) => call('aether_search_collection', { input }) + collection: (input) => call('aether_search_collection', { input }), + library: (input) => call('aether_search_library', { input }) }, semanticTrail: { generate: (input) => call('aether_semantic_trail_generate', { input }) @@ -123,6 +129,7 @@ if (isTauri) { updateSettings: (input) => call('aether_system_update_settings', { input }), updateModels: (input) => call('aether_system_update_models', { input }), checkForUpdate: () => call('aether_system_check_for_update'), + exportLibrary: () => call('aether_system_export_library'), openExternalUrl: (url) => call('aether_system_open_external_url', { url }), downloadModels: (input) => call('aether_system_download_models', { input }) }, diff --git a/src/shared/aether.ts b/src/shared/aether.ts index 0feb666..deabe3a 100644 --- a/src/shared/aether.ts +++ b/src/shared/aether.ts @@ -82,6 +82,17 @@ export interface CaptureResult extends CaptureSummary { collectionName: string } +export interface BulkCaptureFailure { + url: string + reason: string +} + +export interface BulkCaptureResult { + captured: CaptureSummary[] + collectionName: string + failures: BulkCaptureFailure[] +} + export interface CaptureProgress { message: string current?: number @@ -101,6 +112,28 @@ export interface SearchResult { score: number } +export interface LibrarySearchHit { + captureId: string + collectionId: string + collectionName: string + title: string + url: string + host: string + capturedAt: string + excerpt: string + /** 0-100 display score, not raw cosine distance. */ + score: number + chunkMatches: number +} + +export interface LibrarySearchResult { + query: string + hits: LibrarySearchHit[] + /** 'literal' when no embedding model was available to rank semantically. */ + mode: 'semantic' | 'literal' + searchedChunks: number +} + export interface SemanticTrailInput { query?: string limit?: number @@ -338,6 +371,15 @@ export interface SystemStatus { error?: string } +export interface LibraryExportResult { + path: string + exportedAt: string + files: string[] + captureCount: number + chunkCount: number + byteSize: number +} + export interface UpdateCheckResult { currentVersion: string checkedAt: string @@ -442,6 +484,10 @@ export interface AetherApi { } capture: { currentPage(input: { collectionId: string }): Promise + // Captures a page ÆTHER never loaded, by fetching the URL directly. + url(input: { collectionId: string; url: string }): Promise + // Bulk sibling of url(); reports per-link failures instead of aborting. + urls(input: { collectionId: string; urls: string[] }): Promise move(input: { captureId: string; collectionId: string }): Promise delete(captureId: string): Promise suggestHub(): Promise @@ -452,6 +498,12 @@ export interface AetherApi { query: string limit?: number }): Promise + // Grouped one-row-per-source search. Omit collectionId to search every hub. + library(input: { + query: string + collectionId?: string + limit?: number + }): Promise } semanticTrail: { generate(input?: SemanticTrailInput): Promise @@ -489,6 +541,8 @@ export interface AetherApi { updateSettings(input: Partial): Promise updateModels(input: { embeddingModel?: string; chatModel?: string }): Promise checkForUpdate(): Promise + // Snapshots every local store into a timestamped folder and reveals it. + exportLibrary(): Promise openExternalUrl(url: string): Promise downloadModels(input: { chatModels: ModelDownloadChoice[] From 9e904bada8fe387b74dac61cbd67a07cac0c6a0b Mon Sep 17 00:00:00 2001 From: Can Ur Date: Sat, 25 Jul 2026 11:01:39 +0200 Subject: [PATCH 02/15] chunks.json keeps metadata + a vectorSlot; chunks.vec holds raw little-endian f32 at a fixed dim * 4 stride. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Appends are now O(new chunks). Capturing a page writes only its own vectors instead of re-serializing the whole library. That's the quadratic fix — and note that moving a capture between hubs now touches zero vector bytes, since only collectionId changes in the metadata. Crash ordering is deliberate: the sidecar is written and sync_all()'d before the metadata lands. Orphaned vector bytes are harmless; metadata pointing at slots that don't exist would be corruption. That ordering is the whole safety argument. push_chunks is the only slot allocator. The old code did vectors.chunks.extend(...) directly; any caller doing that now would desynchronize the stride index, so allocation lives in one place with a comment saying so. Deletes leave dead slots, reclaimed by compaction once they exceed half the file (with a 512-slot floor, so routine deletes stay cheap). Migration: a v1 store is detected by peeking version before deserializing — necessary because vector is now #[serde(skip)], so the v2 deserializer would have silently discarded every embedding. The v1 file is kept as .bak. Degradation is graceful: a missing or truncated sidecar drops the affected chunks with a warning rather than failing the load, so a damaged sidecar costs re-indexing, not the library. 6 new tests on the risky parts: byte-exact round-trip (plus asserting no vector numbers remain in the JSON), append-only growth (16 → 32 bytes, not a rewrite), v1 migration preserving vectors with .bak retained, missing-sidecar survival, compaction correctly renumbering slots and keeping each vector matched to its chunk, and dimension-mismatch rejection. --- README.md | 22 ++- src-tauri/src/lib.rs | 364 ++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 379 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index fdb5644..231574f 100644 --- a/README.md +++ b/README.md @@ -263,11 +263,19 @@ Current storage paths: ```text /aether-library/library.json /aether-realms/chunks.json +/aether-realms/chunks.vec /aether-settings/settings.json /aether-icebergs/icebergs.json +/aether-backups/aether-export-/ ./aether-models/ ``` +Every store is written temp-file-then-rename and keeps the previous good copy as +`.bak`. On load, an unreadable store falls back to its backup, and the damaged +file is preserved as `.corrupt-` rather than overwritten. Settings +also offers **Export Library**, which snapshots every store into a timestamped folder +under `aether-backups/`. + `library.json` stores: - Knowledge hub summaries. @@ -277,10 +285,10 @@ Current storage paths: - Chunk counts. - Legacy migration flags. -`chunks.json` stores embedded chunk rows: +`chunks.json` stores chunk **metadata** (store format v2): - `id` -- `vector` +- `vectorSlot` — index into `chunks.vec` - `text` - `collectionId` - `captureId` @@ -290,6 +298,16 @@ Current storage paths: - `capturedAt` - `chunkIndex` +`chunks.vec` stores the embeddings themselves as raw little-endian `f32`, at a fixed +stride of `dim * 4` bytes per slot. Keeping vectors out of JSON matters at scale: as +decimal text a 1024-dim vector costs roughly 12 KB versus 4 KB binary, and — more +importantly — the sidecar is **append-only**, so capturing a page writes only the new +vectors instead of re-serializing every vector in the library. Deleting sources leaves +dead slots behind; once they exceed half the file, the store compacts and renumbers. + +A v1 `chunks.json` (vectors inline) is migrated automatically on first load, and the +original file is kept as `chunks.json.bak`. + `settings.json` stores app preferences such as the default search engine, Developer Mode, and selected local model paths. `icebergs.json` stores manually saved iCE generations: diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2167264..cc87291 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3798,6 +3798,8 @@ async fn capture_page_into_collection( .map(|(index, text)| ChunkRecord { id: uuid(), vector: embeddings[index].clone(), + // Assigned by push_chunks when the store accepts the record. + vector_slot: 0, text, collection_id: collection.id.clone(), capture_id: capture_id.clone(), @@ -3809,8 +3811,9 @@ async fn capture_page_into_collection( }) .collect::>(); + // push_chunks assigns the sidecar slots; never extend `chunks` directly. with_vectors_mut(state, |vectors| { - vectors.chunks.extend(records.iter().cloned()); + vectors.push_chunks(records.iter().cloned()); }) .await?; @@ -4396,9 +4399,13 @@ async fn aether_system_export_library( .await .map_err(|error| error.to_string())?; - let sources = [ + // chunks.json holds only metadata; without the binary sidecar beside it the + // export would restore a library whose sources cannot be searched. + let chunks_vec_path = vector_data_path(&paths.chunks_path); + let sources: [(&PathBuf, &str); 5] = [ (&paths.library_path, "library.json"), (&paths.chunks_path, "chunks.json"), + (&chunks_vec_path, "chunks.vec"), (&paths.settings_path, "settings.json"), (&paths.icebergs_path, "icebergs.json"), ]; @@ -6465,7 +6472,103 @@ async fn load_icebergs(path: &Path) -> Cmd { } async fn load_vectors(path: &Path) -> Cmd { - read_json_or_default(path).await + // A v1 store carries its vectors inline, so it must be detected before the v2 + // deserializer silently drops them (`vector` is #[serde(skip)] there). + if let Ok(raw) = tokio::fs::read_to_string(path).await { + let declared_version = serde_json::from_str::(&raw) + .ok() + .and_then(|value| value.get("version").and_then(serde_json::Value::as_u64)); + if declared_version.unwrap_or(1) < VECTOR_STORE_VERSION as u64 { + return migrate_legacy_vectors(path, &raw).await; + } + } + + let mut data: VectorStoreData = read_json_or_default(path).await?; + hydrate_vectors(path, &mut data).await?; + Ok(data) +} + +// Reads the sidecar and attaches each chunk's vector. Chunks whose slot is missing +// from the file are dropped rather than fatal: a partial sidecar should cost the +// affected sources, not the whole library. +async fn hydrate_vectors(path: &Path, data: &mut VectorStoreData) -> Cmd<()> { + if data.chunks.is_empty() { + return Ok(()); + } + if data.dim == 0 { + eprintln!("aether: vector store has chunks but no dimension; dropping stale metadata"); + data.chunks.clear(); + return Ok(()); + } + + let vector_path = vector_data_path(path); + let bytes = match tokio::fs::read(&vector_path).await { + Ok(bytes) => bytes, + Err(error) => { + eprintln!( + "aether: vector sidecar {} unreadable ({error}); captures need re-indexing", + vector_path.display() + ); + data.chunks.clear(); + return Ok(()); + } + }; + + let stride = data.dim * 4; + let available = (bytes.len() / stride) as u64; + let before = data.chunks.len(); + + data.chunks.retain(|chunk| chunk.vector_slot < available); + for chunk in &mut data.chunks { + let start = chunk.vector_slot as usize * stride; + chunk.vector = bytes[start..start + stride] + .chunks_exact(4) + .map(|word| f32::from_le_bytes([word[0], word[1], word[2], word[3]])) + .collect(); + } + + if data.chunks.len() != before { + eprintln!( + "aether: dropped {} chunk(s) missing from the vector sidecar", + before - data.chunks.len() + ); + } + Ok(()) +} + +async fn migrate_legacy_vectors(path: &Path, raw: &str) -> Cmd { + let legacy: LegacyVectorStoreData = match serde_json::from_str(raw) { + Ok(legacy) => legacy, + Err(error) => { + eprintln!("aether: could not read legacy vector store ({error}); starting fresh"); + return Ok(VectorStoreData::default()); + } + }; + + let mut data = VectorStoreData::default(); + data.push_chunks(legacy.chunks.into_iter().map(|chunk| ChunkRecord { + id: chunk.id, + vector: chunk.vector, + vector_slot: 0, + text: chunk.text, + collection_id: chunk.collection_id, + capture_id: chunk.capture_id, + title: chunk.title, + url: chunk.url, + app_id: chunk.app_id, + captured_at: chunk.captured_at, + chunk_index: chunk.chunk_index, + })); + + eprintln!( + "aether: migrating {} chunk(s) to the binary vector store", + data.chunks.len() + ); + // Rewrite the sidecar from scratch; save_vectors rotates the v1 JSON to .bak, so + // the original file survives the migration. + write_vector_sidecar(path, &data, 0).await?; + save_vector_metadata(path, &data).await?; + Ok(data) } async fn with_vectors_read( @@ -6499,13 +6602,97 @@ async fn with_vectors_mut( Ok(result) } -// Vector rows are large and machine-managed, so they are persisted as compact +// Vector rows are large and machine-managed, so the metadata is persisted as compact // JSON instead of the pretty format used for small user-editable stores. -async fn save_vectors(path: &Path, data: &VectorStoreData) -> Cmd<()> { +async fn save_vector_metadata(path: &Path, data: &VectorStoreData) -> Cmd<()> { let raw = serde_json::to_string(data).map_err(|error| error.to_string())?; write_store_durably(path, raw.as_bytes()).await } +// Writes vectors for every chunk whose slot is at or beyond `slots_present`. +// `slots_present == 0` rewrites the sidecar from scratch. +async fn write_vector_sidecar( + path: &Path, + data: &VectorStoreData, + slots_present: u64, +) -> Cmd<()> { + let vector_path = vector_data_path(path); + ensure_parent_dir(&vector_path).await?; + + let mut pending = data + .chunks + .iter() + .filter(|chunk| chunk.vector_slot >= slots_present) + .collect::>(); + if pending.is_empty() && slots_present > 0 { + return Ok(()); + } + // Slot order is file order; appending out of order would corrupt the stride index. + pending.sort_by_key(|chunk| chunk.vector_slot); + + let mut buffer = Vec::with_capacity(pending.len() * data.dim.max(1) * 4); + for chunk in pending { + for value in &chunk.vector { + buffer.extend_from_slice(&value.to_le_bytes()); + } + } + + let mut file = tokio::fs::OpenOptions::new() + .create(true) + .write(true) + .append(slots_present > 0) + .truncate(slots_present == 0) + .open(&vector_path) + .await + .map_err(|error| error.to_string())?; + file.write_all(&buffer) + .await + .map_err(|error| error.to_string())?; + // The metadata written next will reference these slots, so they must be on disk + // before it lands. Orphaned vector bytes are harmless; dangling slots are not. + file.sync_all().await.map_err(|error| error.to_string()) +} + +async fn sidecar_slots_present(path: &Path, dim: usize) -> u64 { + if dim == 0 { + return 0; + } + match tokio::fs::metadata(vector_data_path(path)).await { + Ok(meta) => meta.len() / (dim as u64 * 4), + Err(_) => 0, + } +} + +// Deletes leave dead slots behind. Once they dominate the sidecar, renumber the live +// chunks and rewrite it so the file cannot grow without bound. +async fn compact_vectors_if_needed(path: &Path, data: &mut VectorStoreData) -> Cmd { + let live = data.chunks.len() as u64; + if data.next_slot < VECTOR_COMPACTION_MIN_SLOTS { + return Ok(false); + } + let dead = data.next_slot.saturating_sub(live); + if (dead as f64) < data.next_slot as f64 * VECTOR_COMPACTION_DEAD_RATIO { + return Ok(false); + } + + data.chunks.sort_by_key(|chunk| chunk.vector_slot); + for (index, chunk) in data.chunks.iter_mut().enumerate() { + chunk.vector_slot = index as u64; + } + data.next_slot = live; + eprintln!("aether: compacted vector store, reclaimed {dead} dead slot(s)"); + write_vector_sidecar(path, data, 0).await?; + Ok(true) +} + +async fn save_vectors(path: &Path, data: &mut VectorStoreData) -> Cmd<()> { + if !compact_vectors_if_needed(path, data).await? { + let slots_present = sidecar_slots_present(path, data.dim).await; + write_vector_sidecar(path, data, slots_present).await?; + } + save_vector_metadata(path, data).await +} + fn backup_path(path: &Path) -> PathBuf { let mut name = path.file_name().unwrap_or_default().to_os_string(); name.push(BACKUP_SUFFIX); @@ -9420,10 +9607,176 @@ mod tests { assert_eq!(corrupt_count(&dir.0), 0); } + fn vector_chunk(capture_id: &str, vector: Vec) -> ChunkRecord { + ChunkRecord { + id: uuid(), + vector, + vector_slot: 0, + text: format!("text for {capture_id}"), + collection_id: "hub-1".to_string(), + capture_id: capture_id.to_string(), + title: format!("Title {capture_id}"), + url: format!("https://example.com/{capture_id}"), + app_id: "browser".to_string(), + captured_at: "2026-07-01T00:00:00Z".to_string(), + chunk_index: 0, + } + } + + // Vectors must survive the JSON/sidecar split byte-for-byte; a silent precision or + // ordering change here would quietly degrade every future search result. + #[test] + fn vector_store_round_trips_vectors_through_the_sidecar() { + let dir = TempDir::new(); + let path = dir.path("chunks.json"); + + let mut store = VectorStoreData::default(); + store.push_chunks(vec![ + vector_chunk("a", vec![0.5, -0.25, 0.125, 1.0]), + vector_chunk("b", vec![-1.0, 0.0, 0.75, 0.5]), + ]); + block_on(save_vectors(&path, &mut store)).expect("save"); + + let loaded = block_on(load_vectors(&path)).expect("load"); + + assert_eq!(loaded.version, VECTOR_STORE_VERSION); + assert_eq!(loaded.dim, 4); + assert_eq!(loaded.chunks.len(), 2); + assert_eq!(loaded.chunks[0].vector, vec![0.5, -0.25, 0.125, 1.0]); + assert_eq!(loaded.chunks[1].vector, vec![-1.0, 0.0, 0.75, 0.5]); + // The whole point of the split: no vector numbers in the JSON. + let raw = fs::read_to_string(&path).expect("metadata"); + assert!(!raw.contains("0.125"), "vectors must not be in the JSON"); + } + + #[test] + fn vector_store_appends_without_rewriting_existing_slots() { + let dir = TempDir::new(); + let path = dir.path("chunks.json"); + + let mut store = VectorStoreData::default(); + store.push_chunks(vec![vector_chunk("a", vec![1.0, 2.0, 3.0, 4.0])]); + block_on(save_vectors(&path, &mut store)).expect("first save"); + let size_after_first = fs::metadata(vector_data_path(&path)).expect("sidecar").len(); + + store.push_chunks(vec![vector_chunk("b", vec![5.0, 6.0, 7.0, 8.0])]); + block_on(save_vectors(&path, &mut store)).expect("second save"); + let size_after_second = fs::metadata(vector_data_path(&path)).expect("sidecar").len(); + + // 4 dims * 4 bytes per append. + assert_eq!(size_after_first, 16); + assert_eq!(size_after_second, 32); + + let loaded = block_on(load_vectors(&path)).expect("load"); + assert_eq!(loaded.chunks[0].vector, vec![1.0, 2.0, 3.0, 4.0]); + assert_eq!(loaded.chunks[1].vector, vec![5.0, 6.0, 7.0, 8.0]); + } + + // Existing installs have a v1 store with inline vectors. Losing them on upgrade + // would silently empty every hub, so migration is the highest-risk path here. + #[test] + fn vector_store_migrates_a_v1_json_store_without_losing_vectors() { + let dir = TempDir::new(); + let path = dir.path("chunks.json"); + let legacy = serde_json::json!({ + "version": 1, + "chunks": [ + { + "id": "chunk-1", + "vector": [0.25, 0.5, 0.75, 1.0], + "text": "legacy text", + "collectionId": "hub-1", + "captureId": "capture-1", + "title": "Legacy source", + "url": "https://example.com/legacy", + "appId": "browser", + "capturedAt": "2026-06-01T00:00:00Z", + "chunkIndex": 0 + } + ] + }); + fs::write(&path, serde_json::to_string(&legacy).unwrap()).expect("seed v1"); + + let migrated = block_on(load_vectors(&path)).expect("migrate"); + + assert_eq!(migrated.version, VECTOR_STORE_VERSION); + assert_eq!(migrated.chunks.len(), 1); + assert_eq!(migrated.chunks[0].vector, vec![0.25, 0.5, 0.75, 1.0]); + assert_eq!(migrated.chunks[0].text, "legacy text"); + // The pre-migration file must still be recoverable. + assert!(backup_path(&path).exists(), "v1 store should be kept as .bak"); + + // Reloading must now take the v2 path and produce the same vectors. + let reloaded = block_on(load_vectors(&path)).expect("reload"); + assert_eq!(reloaded.chunks[0].vector, vec![0.25, 0.5, 0.75, 1.0]); + } + + #[test] + fn vector_store_survives_a_missing_sidecar_without_losing_the_library() { + let dir = TempDir::new(); + let path = dir.path("chunks.json"); + + let mut store = VectorStoreData::default(); + store.push_chunks(vec![vector_chunk("a", vec![1.0, 0.0, 0.0, 0.0])]); + block_on(save_vectors(&path, &mut store)).expect("save"); + fs::remove_file(vector_data_path(&path)).expect("drop sidecar"); + + let loaded = block_on(load_vectors(&path)).expect("load without sidecar"); + + // Chunks are dropped (they cannot be searched) but the load itself succeeds. + assert!(loaded.chunks.is_empty()); + } + + #[test] + fn vector_store_compacts_once_dead_slots_dominate() { + let dir = TempDir::new(); + let path = dir.path("chunks.json"); + + let mut store = VectorStoreData::default(); + let total = VECTOR_COMPACTION_MIN_SLOTS as usize + 8; + store.push_chunks( + (0..total).map(|index| vector_chunk(&format!("c{index}"), vec![index as f32, 0.0, 0.0, 0.0])), + ); + block_on(save_vectors(&path, &mut store)).expect("save"); + assert_eq!(store.next_slot, total as u64); + + // Drop most chunks, which leaves their slots dead. + store.chunks.retain(|chunk| chunk.vector_slot % 8 == 0); + let survivors = store.chunks.len() as u64; + block_on(save_vectors(&path, &mut store)).expect("save after delete"); + + assert_eq!(store.next_slot, survivors, "slots should be renumbered"); + let sidecar = fs::metadata(vector_data_path(&path)).expect("sidecar").len(); + assert_eq!(sidecar, survivors * 4 * 4, "dead slots should be reclaimed"); + + let loaded = block_on(load_vectors(&path)).expect("load"); + assert_eq!(loaded.chunks.len() as u64, survivors); + // Values must still line up with their chunks after renumbering. + for chunk in &loaded.chunks { + let expected: f32 = chunk.capture_id.trim_start_matches('c').parse().unwrap(); + assert_eq!(chunk.vector[0], expected); + } + } + + #[test] + fn vector_store_rejects_chunks_with_mismatched_dimensions() { + let mut store = VectorStoreData::default(); + store.push_chunks(vec![ + vector_chunk("a", vec![1.0, 2.0, 3.0, 4.0]), + vector_chunk("b", vec![1.0, 2.0]), + vector_chunk("c", vec![]), + ]); + + assert_eq!(store.dim, 4); + assert_eq!(store.chunks.len(), 1, "only the matching chunk is stored"); + assert_eq!(store.next_slot, 1); + } + fn chunk_for_search(capture_id: &str, title: &str, url: &str, text: &str) -> ChunkRecord { ChunkRecord { id: uuid(), vector: vec![0.0; 4], + vector_slot: 0, text: text.to_string(), collection_id: "hub-1".to_string(), capture_id: capture_id.to_string(), @@ -9832,6 +10185,7 @@ mod tests { chunk: ChunkRecord { id: uuid(), vector: vec![1.0, 0.0], + vector_slot: 0, text: text.to_string(), collection_id: "collection".to_string(), capture_id: "capture".to_string(), From 2ceb925e318529fab7f68ded680f264d975a854d Mon Sep 17 00:00:00 2001 From: Can Ur Date: Sat, 25 Jul 2026 11:06:55 +0200 Subject: [PATCH 03/15] Only require MiST model, the rest being optional --- README.md | 16 +++- src-tauri/src/lib.rs | 82 +++++++++++++++++-- src/renderer/src/App.tsx | 8 +- .../src/assets/styles/intelligence-panel.css | 14 ++++ .../src/assets/styles/setup-settings.css | 6 ++ .../src/components/IntelligencePanel.tsx | 13 ++- .../src/components/ModelSetupModal.tsx | 11 ++- 7 files changed, 135 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 231574f..3b1e636 100644 --- a/README.md +++ b/README.md @@ -87,15 +87,23 @@ Fresh installs use **AiON Launch**, the in-app setup flow for downloading local | Model | Role | Official source | Size | | ------------- | ------------------------------------------------------------- | ------------------------------------- | -------: | | **AiON MiST** | Required embedding model for search, capture, and retrieval | `Qwen/Qwen3-Embedding-0.6B-GGUF` | ~0.64 GB | -| **AiON LiTE** | Smaller, faster chat model for everyday answers and summaries | `google/gemma-4-E2B-it-qat-q4_0-gguf` | ~3.35 GB | -| **AiON WiSE** | Larger chat model for richer synthesis and iCE maps | `google/gemma-4-E4B-it-qat-q4_0-gguf` | ~5.15 GB | +| **AiON LiTE** | Optional chat model for everyday answers and summaries | `google/gemma-4-E2B-it-qat-q4_0-gguf` | ~3.35 GB | +| **AiON WiSE** | Optional chat model for richer synthesis and iCE maps | `google/gemma-4-E4B-it-qat-q4_0-gguf` | ~5.15 GB | Install choices: -- **MiST + LiTE**: best default for laptops, mobile-class hardware, and quick grounded answers. -- **MiST + WiSE**: better for deeper synthesis, longer answers, and iCE generation. +- **MiST alone (~0.64 GB)**: the smallest complete install. Capture, semantic search + across hubs, Flow, and Ask all work — Ask returns the best-matching passages from + your sources with citations instead of a written answer. Chat models can be added + later from Settings without re-indexing anything. +- **MiST + LiTE**: adds written answers. Best default for laptops and mobile-class + hardware. +- **MiST + WiSE**: deeper synthesis, longer answers, and iCE map generation. - **MiST + LiTE + WiSE**: lets the user switch between speed and depth. +Only **iCE map generation** strictly requires a chat model; everything else degrades +to retrieval rather than failing. + Manual development installs can also place models here: ```text diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cc87291..c504fae 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6905,12 +6905,21 @@ async fn local_chat( ) -> Cmd { let started_at = Instant::now(); let catalog = model_catalog(&state.paths, &settings.local_model); - let model_path = catalog.chat_model.ok_or_else(|| { - format!( - "No local chat GGUF model found. Add Gemma or another chat model to {} or set {AETHER_CHAT_MODEL_ENV}.", - state.paths.models_path.display() - ) - })?; + // With only the embedding model installed there is nothing to generate with, but + // retrieval still works. Returning the ranked passages is far more useful than an + // error, and it is what makes MiST-only a usable install rather than a dead end. + let Some(model_path) = catalog.chat_model else { + if citations.is_empty() { + return Err(format!( + "No local chat model is installed, and no captured passages matched. Capture a page or install a chat model in {}.", + state.paths.models_path.display() + )); + } + if let Some(stream) = &stream { + stream.citations(&citations); + } + return Ok(extractive_answer(citations, started_at.elapsed().as_secs_f64())); + }; if let Some(stream) = &stream { stream.citations(&citations); } @@ -6960,6 +6969,41 @@ async fn local_chat( }) } +// The retrieval-only answer. Deliberately presented as quoted passages with their +// sources rather than as prose: nothing here was generated, and dressing excerpts up +// as an answer would imply reasoning that did not happen. +const EXTRACTIVE_MODEL_LABEL: &str = "AiON MiST (passages only)"; + +fn extractive_answer(citations: Vec, elapsed_seconds: f64) -> ChatResult { + let mut answer = String::from( + "No chat model is installed, so ÆTHER cannot write an answer. These are the passages from your library that best match the question:\n\n", + ); + for (index, citation) in citations.iter().enumerate() { + answer.push_str(&format!( + "**{}. {}** [{}]\n\n> {}\n\n", + index + 1, + citation.title.trim(), + index + 1, + semantic_trail_excerpt(citation.text.trim(), 480).replace('\n', " ") + )); + } + answer.push_str("_Install a chat model in Settings to get written answers grounded in these sources._"); + + let chunks = citations.len(); + ChatResult { + answer, + model: EXTRACTIVE_MODEL_LABEL.to_string(), + citations, + metrics: ChatMetrics { + // Nothing was generated, so the token metrics are honestly zero. + generated_tokens: 0, + tokens_per_second: 0.0, + elapsed_seconds, + chunks, + }, + } +} + async fn local_generate_iceberg( state: &State<'_, Backend>, settings: &UserSettings, @@ -9607,6 +9651,32 @@ mod tests { assert_eq!(corrupt_count(&dir.0), 0); } + // With no chat model the answer must be visibly a passage list, and must not claim + // generation metrics it did not earn. + #[test] + fn extractive_answer_quotes_sources_and_reports_no_generated_tokens() { + let citations = vec![ + search_result("1", "https://example.com/a", "Quantum mechanics arose gradually."), + search_result("2", "https://example.com/b", "The Schrodinger equation governs."), + ]; + + let result = extractive_answer(citations, 0.25); + + assert_eq!(result.metrics.generated_tokens, 0); + assert_eq!(result.metrics.tokens_per_second, 0.0); + assert_eq!(result.metrics.chunks, 2); + assert_eq!(result.model, EXTRACTIVE_MODEL_LABEL); + assert!(result.answer.contains("cannot write an answer")); + // Both passages must be present and marked as quotes. + assert!(result.answer.contains("Quantum mechanics arose gradually.")); + assert!(result.answer.contains("The Schrodinger equation governs.")); + assert_eq!(result.answer.matches("\n> ").count(), 2); + // Citation markers must line up with the citation list for the UI. + assert!(result.answer.contains("[1]")); + assert!(result.answer.contains("[2]")); + assert_eq!(result.citations.len(), 2); + } + fn vector_chunk(capture_id: &str, vector: Vec) -> ChunkRecord { ChunkRecord { id: uuid(), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 9713afe..77c642c 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -530,10 +530,13 @@ function App(): React.JSX.Element { : '' const currentPageTint = canUseCurrentPage && activeTab ? getTabTint(activeTab.host, activeTab.themeColor) : '' - const chatBlocked = status ? !status.runtimeReady || !status.chatModel : false + // Only the embedding model is required. Without a chat model, Ask degrades to ranked + // cited passages rather than being blocked, so a 0.64 GB install is fully usable. + const chatBlocked = status ? !status.runtimeReady || !status.embeddingModel : false + const chatIsExtractive = Boolean(status && status.embeddingModel && !status.chatModel) const installedSetupModels = useMemo(() => installedSetupModelsFromStatus(status), [status]) const modelSetupCoreInstalled = setupCoreInstalled(status) - const modelSetupNeeded = Boolean(status && (!status.chatModel || !status.embeddingModel)) + const modelSetupNeeded = Boolean(status && !status.embeddingModel) const modelSetupBusy = Boolean( busy === 'Installing local models' || modelDownloadProgress.some( @@ -2480,6 +2483,7 @@ function App(): React.JSX.Element { - Ask AiON + {chatIsExtractive ? 'Find Passages' : 'Ask AiON'} + {/* Say up front that nothing will be written, so a passage list is not + read as a failed answer. */} + {chatIsExtractive && ( +

+ No chat model installed — AiON will return the best matching passages + from your sources instead of a written answer. +

+ )}
diff --git a/src/renderer/src/components/ModelSetupModal.tsx b/src/renderer/src/components/ModelSetupModal.tsx index be48de4..2dd1407 100644 --- a/src/renderer/src/components/ModelSetupModal.tsx +++ b/src/renderer/src/components/ModelSetupModal.tsx @@ -132,7 +132,8 @@ export function ModelSetupModal({ ? 'Begin Install' : coreInstalled ? 'All Installed' - : 'Install Core' + : // Name the size, because 639 MB versus 4 GB is the whole decision. + 'Install MiST only · 639 MB' return (
@@ -152,7 +153,8 @@ export function ModelSetupModal({ Choose the local model pack for ascending

- AiON MiST installs with every selection for private semantic search + AiON MiST is all ÆTHER needs — chat models are optional and can be + added any time

@@ -210,6 +212,11 @@ export function ModelSetupModal({ : 'The misty semantic search core · 639 MB'} {!coreInstalled ? Qwen3 Embedding 0.6B Q8_0 : <>} + {/* Without this, an empty checkbox list reads as an unfinished setup + rather than a valid, complete install. */} + + Enough on its own: capture, semantic search, Flow, and cited passages +
From 866527e10ce224fd69295383ddea868f8d6cf5dc Mon Sep 17 00:00:00 2001 From: Can Ur Date: Sat, 25 Jul 2026 11:15:49 +0200 Subject: [PATCH 04/15] chat history efficiency + update deps --- README.md | 6 + bun.lock | 186 ++++------- package.json | 28 +- src-tauri/src/lib.rs | 316 +++++++++++++++++- src/renderer/src/App.tsx | 41 +++ .../src/assets/styles/intelligence-panel.css | 54 +++ .../src/components/IntelligencePanel.tsx | 40 +++ src/renderer/src/tauri-aether.ts | 6 +- src/shared/aether.ts | 14 + 9 files changed, 537 insertions(+), 154 deletions(-) diff --git a/README.md b/README.md index 3b1e636..ee368ec 100644 --- a/README.md +++ b/README.md @@ -274,6 +274,7 @@ Current storage paths: /aether-realms/chunks.vec /aether-settings/settings.json /aether-icebergs/icebergs.json +/aether-conversations/conversations.json /aether-backups/aether-export-/ ./aether-models/ ``` @@ -316,6 +317,11 @@ dead slots behind; once they exceed half the file, the store compacts and renumb A v1 `chunks.json` (vectors inline) is migrated automatically on first load, and the original file is kept as `chunks.json.bak`. +`conversations.json` stores the AiON thread per knowledge hub (plus one thread for +current-page-only asks): prompt, answer, model, citations, and metrics for each turn. +The most recent turns are replayed into the prompt so follow-up questions work, and +each thread keeps its last 40 turns. + `settings.json` stores app preferences such as the default search engine, Developer Mode, and selected local model paths. `icebergs.json` stores manually saved iCE generations: diff --git a/bun.lock b/bun.lock index 1825352..f7dcbfe 100644 --- a/bun.lock +++ b/bun.lock @@ -64,63 +64,11 @@ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], + "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], - "@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], + "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], - - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], - - "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], - - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], - - "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], - - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], - - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], - - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], - - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], - - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], - - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], - - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], - - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], - - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], - - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], - - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], - - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], - - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], - - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], - - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], - - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], - - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], - - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], - - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], - - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], - - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], - - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], @@ -128,15 +76,15 @@ "@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="], - "@eslint/config-helpers": ["@eslint/config-helpers@0.6.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA=="], + "@eslint/config-helpers": ["@eslint/config-helpers@0.7.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw=="], "@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="], - "@eslint/js": ["@eslint/js@9.39.4", "", {}, "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw=="], + "@eslint/js": ["@eslint/js@9.39.5", "", {}, "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A=="], "@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], - "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="], + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.2", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A=="], "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], @@ -158,69 +106,69 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], - "@oxc-project/types": ["@oxc-project/types@0.132.0", "", {}, "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ=="], + "@oxc-project/types": ["@oxc-project/types@0.139.0", "", {}, "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw=="], - "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.2", "", { "os": "android", "cpu": "arm64" }, "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.5", "", { "os": "android", "cpu": "arm64" }, "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ=="], - "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w=="], + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw=="], - "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA=="], + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g=="], - "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA=="], + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA=="], - "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w=="], + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.5", "", { "os": "linux", "cpu": "arm" }, "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw=="], - "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig=="], + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q=="], - "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw=="], + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA=="], - "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA=="], + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg=="], - "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ=="], + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA=="], - "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ=="], + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ=="], - "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw=="], + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg=="], - "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w=="], + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.5", "", { "os": "none", "cpu": "arm64" }, "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw=="], - "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.2", "", { "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@napi-rs/wasm-runtime": "^1.1.4" }, "cpu": "none" }, "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ=="], + "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.5", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA=="], - "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A=="], + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw=="], - "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ=="], + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], - "@tauri-apps/api": ["@tauri-apps/api@2.11.0", "", {}, "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA=="], + "@tauri-apps/api": ["@tauri-apps/api@2.11.1", "", {}, "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA=="], - "@tauri-apps/cli": ["@tauri-apps/cli@2.11.2", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.11.2", "@tauri-apps/cli-darwin-x64": "2.11.2", "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.2", "@tauri-apps/cli-linux-arm64-gnu": "2.11.2", "@tauri-apps/cli-linux-arm64-musl": "2.11.2", "@tauri-apps/cli-linux-riscv64-gnu": "2.11.2", "@tauri-apps/cli-linux-x64-gnu": "2.11.2", "@tauri-apps/cli-linux-x64-musl": "2.11.2", "@tauri-apps/cli-win32-arm64-msvc": "2.11.2", "@tauri-apps/cli-win32-ia32-msvc": "2.11.2", "@tauri-apps/cli-win32-x64-msvc": "2.11.2" }, "bin": { "tauri": "tauri.js" } }, "sha512-bk3HemqvGRoy+5D/dVMUQHKMYLglD0jVnMm/0iGMH6ufZ+p8r14m6BpIixwij3PBvZdvORUp1YifTD8QxVZ1Nw=="], + "@tauri-apps/cli": ["@tauri-apps/cli@2.11.4", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.11.4", "@tauri-apps/cli-darwin-x64": "2.11.4", "@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4", "@tauri-apps/cli-linux-arm64-gnu": "2.11.4", "@tauri-apps/cli-linux-arm64-musl": "2.11.4", "@tauri-apps/cli-linux-riscv64-gnu": "2.11.4", "@tauri-apps/cli-linux-x64-gnu": "2.11.4", "@tauri-apps/cli-linux-x64-musl": "2.11.4", "@tauri-apps/cli-win32-arm64-msvc": "2.11.4", "@tauri-apps/cli-win32-ia32-msvc": "2.11.4", "@tauri-apps/cli-win32-x64-msvc": "2.11.4" }, "bin": { "tauri": "tauri.js" } }, "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ=="], - "@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.11.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+4UZzLt+eOAEQCwgd+TqKgyUJMrvx+BgdXLLaqJYmPqzP+nE6YZr/hY6CWLYGQb8jFn99jEkmC6uA3tNvamA1w=="], + "@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.11.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ=="], - "@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.11.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-VjYYtZUPqDMLutSfJEyxFE3Bz+DPi7c8wC3imckgvciLDZLq4qwKJxBicg0BXGhXjJsl8vKWgWRFNMPELQ+Xyg=="], + "@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.11.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A=="], - "@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.11.2", "", { "os": "linux", "cpu": "arm" }, "sha512-yMemD6f4i95AQriS8EazyOFzbE34yjnP16i3IOzpHGQvBoy2DjypFMFBq0NtPuITURv/cOGguRtHR5d79/9CSA=="], + "@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.11.4", "", { "os": "linux", "cpu": "arm" }, "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ=="], - "@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.11.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-cgI91D2wL8GSgoWwZXDqt+DwnuZCP2/bz03QAE4TrhgAKIsrB4hX26W/H1EONPUUNkqrsgeCD0wU6pcNjV/5kw=="], + "@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.11.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA=="], - "@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.11.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw=="], + "@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.11.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw=="], - "@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.11.2", "", { "os": "linux", "cpu": "none" }, "sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ=="], + "@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.11.4", "", { "os": "linux", "cpu": "none" }, "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ=="], - "@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.11.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw=="], + "@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.11.4", "", { "os": "linux", "cpu": "x64" }, "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ=="], - "@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.11.2", "", { "os": "linux", "cpu": "x64" }, "sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw=="], + "@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.11.4", "", { "os": "linux", "cpu": "x64" }, "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A=="], - "@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.11.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA=="], + "@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.11.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ=="], - "@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.11.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YhjQNZcXfbkCLyazSv1nPnJ9iRFE1wm6kc51FDbU10/Dk09io+6PAGMLjkxnX2GdM0qMnDmTjstY8mTDVvtKeA=="], + "@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.11.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA=="], - "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.2", "", { "os": "win32", "cpu": "x64" }, "sha512-d2JchlFIpZevZVReyqhQOekJmb1UH3rhZ5VX6sH3ty9ETE0TKQavpihvoScUXfKKpW6HZC0MrFGRU0ZtD+w3gA=="], + "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.11.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw=="], - "@tybys/wasm-util": ["@tybys/wasm-util@0.10.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], @@ -230,33 +178,33 @@ "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + "@types/node": ["@types/node@25.9.5", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg=="], - "@types/react": ["@types/react@19.2.15", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q=="], + "@types/react": ["@types/react@19.2.17", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/type-utils": "8.59.2", "@typescript-eslint/utils": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.65.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.65.0", "@typescript-eslint/type-utils": "8.65.0", "@typescript-eslint/utils": "8.65.0", "@typescript-eslint/visitor-keys": "8.65.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.65.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA=="], - "@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ=="], + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.65.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.65.0", "@typescript-eslint/types": "8.65.0", "@typescript-eslint/typescript-estree": "8.65.0", "@typescript-eslint/visitor-keys": "8.65.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA=="], - "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.2", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.2", "@typescript-eslint/types": "^8.59.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw=="], + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.65.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.65.0", "@typescript-eslint/types": "^8.65.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q=="], - "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2" } }, "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg=="], + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.65.0", "", { "dependencies": { "@typescript-eslint/types": "8.65.0", "@typescript-eslint/visitor-keys": "8.65.0" } }, "sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg=="], - "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.2", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw=="], + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.65.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg=="], - "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/utils": "8.59.2", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ=="], + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.65.0", "", { "dependencies": { "@typescript-eslint/types": "8.65.0", "@typescript-eslint/typescript-estree": "8.65.0", "@typescript-eslint/utils": "8.65.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g=="], - "@typescript-eslint/types": ["@typescript-eslint/types@8.59.2", "", {}, "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.65.0", "", {}, "sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg=="], - "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.2", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.2", "@typescript-eslint/tsconfig-utils": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg=="], + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.65.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.65.0", "@typescript-eslint/tsconfig-utils": "8.65.0", "@typescript-eslint/types": "8.65.0", "@typescript-eslint/visitor-keys": "8.65.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg=="], - "@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q=="], + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.65.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.65.0", "@typescript-eslint/types": "8.65.0", "@typescript-eslint/typescript-estree": "8.65.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA=="], - "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA=="], + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.65.0", "", { "dependencies": { "@typescript-eslint/types": "8.65.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A=="], - "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.2", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.0" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.4", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg=="], "acorn": ["acorn@8.16.0", "", { "bin": "bin/acorn" }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], @@ -306,19 +254,17 @@ "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], - "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": "bin/esbuild" }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], - "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], - "eslint": ["eslint@10.4.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.6.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ=="], + "eslint": ["eslint@10.8.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ=="], "eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": "bin/cli.js" }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="], "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="], - "eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.5.2", "", { "peerDependencies": { "eslint": "^9 || ^10" } }, "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA=="], + "eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.5.3", "", { "peerDependencies": { "eslint": "^9 || ^10" } }, "sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA=="], "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], @@ -396,8 +342,6 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "jiti": ["jiti@2.7.0", "", { "bin": "lib/jiti-cli.mjs" }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "jsesc": ["jsesc@3.1.0", "", { "bin": "bin/jsesc" }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], @@ -446,7 +390,7 @@ "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - "lucide-react": ["lucide-react@1.17.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w=="], + "lucide-react": ["lucide-react@1.26.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-raglYVR2+VkMfJL158krjVmE+rV5ST2lzA/KQm1FRSjMHT4MnWaegHxoVEpmc2So3nOEhp9oGejJwAPX8MoAjg=="], "macos-alias": ["macos-alias@0.2.12", "", { "dependencies": { "nan": "^2.4.0" }, "os": "darwin" }, "sha512-yiLHa7cfJcGRFq4FrR4tMlpNHb4Vy4mWnpajlSSIFM5k4Lv8/7BbbDLzCAVogWNl0LlLhizRp1drXv0hK9h0Yw=="], @@ -460,7 +404,7 @@ "nan": ["nan@2.27.0", "", {}, "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ=="], - "nanoid": ["nanoid@3.3.12", "", { "bin": "bin/nanoid.cjs" }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + "nanoid": ["nanoid@3.3.16", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q=="], "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], @@ -488,13 +432,13 @@ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], - "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + "postcss": ["postcss@8.5.23", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], - "prettier": ["prettier@3.8.3", "", { "bin": "bin/prettier.cjs" }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="], + "prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="], "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], @@ -502,13 +446,13 @@ "random-path": ["random-path@0.1.2", "", { "dependencies": { "base32-encode": "^0.1.0 || ^1.0.0", "murmur-32": "^0.1.0 || ^0.2.0" } }, "sha512-4jY0yoEaQ5v9StCl5kZbNIQlg1QheIDBrdkDn53EynpPb9FgO6//p3X/tgMnrC45XN6QZCzU1Xz/+pSSsJBpRw=="], - "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], + "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], - "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], + "react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="], "repeat-string": ["repeat-string@1.6.1", "", {}, "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w=="], - "rolldown": ["rolldown@1.0.2", "", { "dependencies": { "@oxc-project/types": "=0.132.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.2", "@rolldown/binding-darwin-arm64": "1.0.2", "@rolldown/binding-darwin-x64": "1.0.2", "@rolldown/binding-freebsd-x64": "1.0.2", "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", "@rolldown/binding-linux-arm64-gnu": "1.0.2", "@rolldown/binding-linux-arm64-musl": "1.0.2", "@rolldown/binding-linux-ppc64-gnu": "1.0.2", "@rolldown/binding-linux-s390x-gnu": "1.0.2", "@rolldown/binding-linux-x64-gnu": "1.0.2", "@rolldown/binding-linux-x64-musl": "1.0.2", "@rolldown/binding-openharmony-arm64": "1.0.2", "@rolldown/binding-wasm32-wasi": "1.0.2", "@rolldown/binding-win32-arm64-msvc": "1.0.2", "@rolldown/binding-win32-x64-msvc": "1.0.2" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g=="], + "rolldown": ["rolldown@1.1.5", "", { "dependencies": { "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.5", "@rolldown/binding-darwin-arm64": "1.1.5", "@rolldown/binding-darwin-x64": "1.1.5", "@rolldown/binding-freebsd-x64": "1.1.5", "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", "@rolldown/binding-linux-arm64-gnu": "1.1.5", "@rolldown/binding-linux-arm64-musl": "1.1.5", "@rolldown/binding-linux-ppc64-gnu": "1.1.5", "@rolldown/binding-linux-s390x-gnu": "1.1.5", "@rolldown/binding-linux-x64-gnu": "1.1.5", "@rolldown/binding-linux-x64-musl": "1.1.5", "@rolldown/binding-openharmony-arm64": "1.1.5", "@rolldown/binding-wasm32-wasi": "1.1.5", "@rolldown/binding-win32-arm64-msvc": "1.1.5", "@rolldown/binding-win32-x64-msvc": "1.1.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], @@ -526,7 +470,7 @@ "strip-eof": ["strip-eof@1.0.0", "", {}, "sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q=="], - "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], "tn1150": ["tn1150@0.1.0", "", { "dependencies": { "unorm": "^1.4.1" } }, "sha512-DbplOfQFkqG5IHcDyyrs/lkvSr3mPUVsFf/RbDppOshs22yTPnSJWEe6FkYd1txAwU/zcnR905ar2fi4kwF29w=="], @@ -540,7 +484,7 @@ "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], - "typescript-eslint": ["typescript-eslint@8.59.2", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.2", "@typescript-eslint/parser": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/utils": "8.59.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ=="], + "typescript-eslint": ["typescript-eslint@8.65.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.65.0", "@typescript-eslint/parser": "8.65.0", "@typescript-eslint/typescript-estree": "8.65.0", "@typescript-eslint/utils": "8.65.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-/ggrHAwyjENDusvyxbuqxAC2dTnZg/Z8F+fgQtYIz+L6n/9HfSlEZcFGV/NsMNa6CkGk0xUjUAFwC0vHOflvIA=="], "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], @@ -550,7 +494,7 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], - "vite": ["vite@8.0.14", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.15", "rolldown": "1.0.2", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.18", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw=="], + "vite": ["vite@8.1.5", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.17", "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -562,8 +506,6 @@ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "yaml": ["yaml@2.8.4", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog=="], - "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], @@ -576,6 +518,8 @@ "@typescript-eslint/typescript-estree/semver": ["semver@7.8.0", "", { "bin": "bin/semver.js" }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], + "bun-types/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + "execa/cross-spawn": ["cross-spawn@6.0.6", "", { "dependencies": { "nice-try": "^1.0.4", "path-key": "^2.0.1", "semver": "^5.5.0", "shebang-command": "^1.2.0", "which": "^1.2.9" } }, "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw=="], "npm-run-path/path-key": ["path-key@2.0.1", "", {}, "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw=="], diff --git a/package.json b/package.json index 6749f26..5e3f705 100644 --- a/package.json +++ b/package.json @@ -46,27 +46,27 @@ }, "dependencies": { "ldrs": "^1.1.9", - "lucide-react": "^1.17.0" + "lucide-react": "^1.26.0" }, "devDependencies": { - "@eslint/js": "^9.39.4", - "@tauri-apps/api": "^2.11.0", - "@tauri-apps/cli": "^2.11.2", + "@eslint/js": "^9.39.5", + "@tauri-apps/api": "^2.11.1", + "@tauri-apps/cli": "^2.11.4", "@types/bun": "^1.3.14", - "@types/node": "^25.9.1", - "@types/react": "^19.2.15", + "@types/node": "^25.9.5", + "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.2", + "@vitejs/plugin-react": "^6.0.4", "appdmg": "^0.6.6", - "eslint": "^10.4.0", + "eslint": "^10.8.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.2", - "prettier": "^3.8.3", - "react": "^19.2.6", - "react-dom": "^19.2.6", + "eslint-plugin-react-refresh": "^0.5.3", + "prettier": "^3.9.6", + "react": "^19.2.8", + "react-dom": "^19.2.8", "typescript": "^6.0.3", - "typescript-eslint": "^8.59.2", - "vite": "^8.0.14" + "typescript-eslint": "^8.65.0", + "vite": "^8.1.5" } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c504fae..7a1e4fb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -41,6 +41,17 @@ const CHUNKS_TABLE: &str = "chunks"; const BACKUP_SUFFIX: &str = ".bak"; const TEMP_WRITE_SUFFIX: &str = ".tmp"; const LIBRARY_EXPORT_DIR: &str = "aether-backups"; +// Thread key for asks scoped to the open page rather than a hub. +const CURRENT_PAGE_THREAD_KEY: &str = "current-page"; +// Turns kept per thread on disk. Enough to reread a session, bounded so the store +// cannot grow without limit. +const MAX_THREAD_TURNS: usize = 40; +// Prior turns replayed into the prompt. Each one costs context that citations also +// need, so this stays small deliberately. +const PROMPT_HISTORY_TURNS: usize = 3; +// Answers from earlier turns are summarised into the prompt, not replayed whole; a +// long previous answer would otherwise crowd out the retrieved sources. +const PROMPT_HISTORY_ANSWER_CHARS: usize = 480; #[cfg(desktop)] const SIDEBAR_WIDTH: f64 = 76.0; #[cfg(desktop)] @@ -87,7 +98,10 @@ const AETHER_EMBED_BATCH_ENV: &str = "AETHER_EMBED_BATCH"; const AETHER_EMBED_BATCH_TOKENS_ENV: &str = "AETHER_EMBED_BATCH_TOKENS"; const AETHER_RELEASES_API_URL: &str = "https://api.github.com/repos/CanPixel/aether/releases/latest"; -const DEFAULT_CHAT_CONTEXT_TOKENS: u32 = 6144; +// 8 citations of ~550 tokens plus a 900-token answer already filled the old 6144 +// window; replaying prior turns needs the extra headroom. Mobile keeps its smaller +// window (see chat_context_tokens) because the KV cache there is the binding limit. +const DEFAULT_CHAT_CONTEXT_TOKENS: u32 = 8192; const DEFAULT_CHAT_BATCH_TOKENS: usize = 2048; const DEFAULT_EMBEDDING_CONTEXT_TOKENS: u32 = 2048; const DEFAULT_EMBEDDING_BATCH_SIZE: usize = 8; @@ -288,6 +302,7 @@ struct DataPaths { library_path: PathBuf, settings_path: PathBuf, icebergs_path: PathBuf, + conversations_path: PathBuf, air_exports_path: PathBuf, chunks_path: PathBuf, models_path: PathBuf, @@ -1080,6 +1095,38 @@ struct PartialUpdateSettings { auto_check: Option, } +// One completed exchange. Stored per thread so a research session survives quitting +// the app, which is the whole point of keeping answers at all. +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ConversationTurn { + id: String, + prompt: String, + answer: String, + model: String, + asked_at: String, + citations: Vec, + metrics: ChatMetrics, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ConversationData { + version: u8, + // Keyed by hub id, or CURRENT_PAGE_THREAD_KEY for page-only asks. + #[serde(default)] + threads: HashMap>, +} + +impl Default for ConversationData { + fn default() -> Self { + Self { + version: 1, + threads: HashMap::new(), + } + } +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] struct LibraryExportResult { @@ -1389,6 +1436,9 @@ impl Backend { library_path: app_data_dir.join("aether-library").join("library.json"), settings_path: app_data_dir.join("aether-settings").join("settings.json"), icebergs_path: app_data_dir.join("aether-icebergs").join("icebergs.json"), + conversations_path: app_data_dir + .join("aether-conversations") + .join("conversations.json"), air_exports_path: app_data_dir.join("aether-air"), exports_path: app_data_dir.join(LIBRARY_EXPORT_DIR), models_path, @@ -2945,6 +2995,8 @@ pub fn run() { aether_air_reveal, aether_chat_ask, aether_chat_cancel, + aether_chat_history, + aether_chat_clear_history, aether_crystallizer_generate, aether_crystallizer_list_saved, aether_crystallizer_get_saved, @@ -4101,7 +4153,59 @@ async fn aether_chat_ask( .into_iter() .take(chat_citation_limit()) .collect::>(); - local_chat(&state, &settings, &prompt, citations, Some(stream)).await + + // Only the most recent turns are replayed; older ones stay on disk for reading but + // would otherwise crowd the retrieved sources out of the context window. + let thread = conversation_thread(&state.paths, input.collection_id.as_deref()).await; + let history = thread + .iter() + .rev() + .take(PROMPT_HISTORY_TURNS) + .rev() + .cloned() + .collect::>(); + + let result = local_chat(&state, &settings, &prompt, citations, &history, Some(stream)).await?; + + // A failed write must not discard the answer the user is already reading. + if let Err(error) = append_conversation_turn( + &state.paths, + input.collection_id.as_deref(), + ConversationTurn { + id: uuid(), + prompt: prompt.clone(), + answer: result.answer.clone(), + model: result.model.clone(), + asked_at: now(), + citations: result.citations.clone(), + metrics: result.metrics.clone(), + }, + ) + .await + { + eprintln!("aether: could not save conversation turn: {error}"); + } + + Ok(result) +} + +#[tauri::command(rename_all = "camelCase")] +async fn aether_chat_history( + state: State<'_, Backend>, + collection_id: Option, +) -> Cmd> { + Ok(conversation_thread(&state.paths, collection_id.as_deref()).await) +} + +#[tauri::command(rename_all = "camelCase")] +async fn aether_chat_clear_history( + state: State<'_, Backend>, + collection_id: Option, +) -> Cmd<()> { + let key = conversation_thread_key(collection_id.as_deref()); + let mut data = load_conversations(&state.paths.conversations_path).await?; + data.threads.remove(&key); + save_json(&state.paths.conversations_path, &data).await } #[tauri::command] @@ -4402,12 +4506,13 @@ async fn aether_system_export_library( // chunks.json holds only metadata; without the binary sidecar beside it the // export would restore a library whose sources cannot be searched. let chunks_vec_path = vector_data_path(&paths.chunks_path); - let sources: [(&PathBuf, &str); 5] = [ + let sources: [(&PathBuf, &str); 6] = [ (&paths.library_path, "library.json"), (&paths.chunks_path, "chunks.json"), (&chunks_vec_path, "chunks.vec"), (&paths.settings_path, "settings.json"), (&paths.icebergs_path, "icebergs.json"), + (&paths.conversations_path, "conversations.json"), ]; let mut files = Vec::new(); @@ -5648,7 +5753,7 @@ async fn air_synthesize_sections( seed_answer.unwrap_or("None"), ice_map.unwrap_or("None") ); - let result = local_chat(state, &settings, &prompt, citations, None).await?; + let result = local_chat(state, &settings, &prompt, citations, &[], None).await?; Ok(( result.model, normalize_model_markdown_citations(&result.answer, sources.len()), @@ -6471,6 +6576,43 @@ async fn load_icebergs(path: &Path) -> Cmd { read_json_or_default(path).await } +async fn load_conversations(path: &Path) -> Cmd { + read_json_or_default(path).await +} + +fn conversation_thread_key(collection_id: Option<&str>) -> String { + collection_id + .filter(|id| !id.is_empty()) + .unwrap_or(CURRENT_PAGE_THREAD_KEY) + .to_string() +} + +async fn conversation_thread(paths: &DataPaths, collection_id: Option<&str>) -> Vec { + let key = conversation_thread_key(collection_id); + load_conversations(&paths.conversations_path) + .await + .unwrap_or_default() + .threads + .remove(&key) + .unwrap_or_default() +} + +async fn append_conversation_turn( + paths: &DataPaths, + collection_id: Option<&str>, + turn: ConversationTurn, +) -> Cmd<()> { + let key = conversation_thread_key(collection_id); + let mut data = load_conversations(&paths.conversations_path).await?; + let thread = data.threads.entry(key).or_default(); + thread.push(turn); + if thread.len() > MAX_THREAD_TURNS { + let overflow = thread.len() - MAX_THREAD_TURNS; + thread.drain(0..overflow); + } + save_json(&paths.conversations_path, &data).await +} + async fn load_vectors(path: &Path) -> Cmd { // A v1 store carries its vectors inline, so it must be detected before the v2 // deserializer silently drops them (`vector` is #[serde(skip)] there). @@ -6901,6 +7043,8 @@ async fn local_chat( settings: &UserSettings, prompt: &str, citations: Vec, + // Prior turns for follow-up questions. Empty for one-shot callers such as AiR. + history: &[ConversationTurn], stream: Option, ) -> Cmd { let started_at = Instant::now(); @@ -6923,7 +7067,7 @@ async fn local_chat( if let Some(stream) = &stream { stream.citations(&citations); } - let messages = build_chat_messages(prompt, &citations); + let messages = build_chat_messages(prompt, &citations, history); let runtime = Arc::clone(&state.native_runtime); let cancel = Arc::clone(&state.generation_cancelled); let model_label = model_label(&model_path); @@ -8279,7 +8423,11 @@ fn chat_snippet_char_limit() -> usize { } } -fn build_chat_messages(prompt: &str, citations: &[SearchResult]) -> Vec { +fn build_chat_messages( + prompt: &str, + citations: &[SearchResult], + history: &[ConversationTurn], +) -> Vec { let snippet_limit = chat_snippet_char_limit(); let context_block = citations .iter() @@ -8312,19 +8460,48 @@ fn build_chat_messages(prompt: &str, citations: &[SearchResult]) -> Vec String { + let cleaned = strip_numeric_bracket_markers(answer); + let trimmed = cleaned.trim(); + if trimmed.chars().count() <= PROMPT_HISTORY_ANSWER_CHARS { + return trimmed.to_string(); + } + let mut condensed = trimmed + .chars() + .take(PROMPT_HISTORY_ANSWER_CHARS) + .collect::(); + condensed.push('…'); + condensed } fn render_model_chat_prompt( @@ -9651,6 +9828,109 @@ mod tests { assert_eq!(corrupt_count(&dir.0), 0); } + fn turn(prompt: &str, answer: &str) -> ConversationTurn { + ConversationTurn { + id: uuid(), + prompt: prompt.to_string(), + answer: answer.to_string(), + model: "test".to_string(), + asked_at: "2026-07-01T00:00:00Z".to_string(), + citations: Vec::new(), + metrics: ChatMetrics { + generated_tokens: 0, + tokens_per_second: 0.0, + elapsed_seconds: 0.0, + chunks: 0, + }, + } + } + + // Follow-ups only work if prior turns actually reach the model, and the current + // question must still come last so it is what gets answered. + #[test] + fn chat_messages_replay_history_before_the_current_question() { + let history = vec![turn("Who was Augustus?", "The first Roman emperor.")]; + let citations = vec![search_result("1", "https://example.com/a", "context text")]; + + let messages = build_chat_messages("What about Tiberius?", &citations, &history); + + let roles = messages.iter().map(|m| m.role).collect::>(); + assert_eq!(roles, vec!["system", "user", "assistant", "user"]); + assert_eq!(messages[1].content, "Who was Augustus?"); + assert_eq!(messages[2].content, "The first Roman emperor."); + assert!(messages[3].content.contains("What about Tiberius?")); + assert!(messages[3].content.contains("context text")); + } + + #[test] + fn chat_messages_without_history_match_the_single_shot_shape() { + let messages = build_chat_messages("Question?", &[], &[]); + + assert_eq!( + messages.iter().map(|m| m.role).collect::>(), + vec!["system", "user"] + ); + assert!(messages[1].content.contains("No stored context was retrieved.")); + } + + // Replaying old citation markers would let the model reuse source numbers that no + // longer point at anything in the current citation list. + #[test] + fn history_answers_are_stripped_of_stale_citation_markers_and_clipped() { + let condensed = condense_history_answer("Augustus won [1] and later reformed Rome [2]."); + assert!(!condensed.contains("[1]")); + assert!(!condensed.contains("[2]")); + assert!(condensed.contains("Augustus won")); + + let long = "x".repeat(PROMPT_HISTORY_ANSWER_CHARS + 200); + let clipped = condense_history_answer(&long); + assert_eq!(clipped.chars().count(), PROMPT_HISTORY_ANSWER_CHARS + 1); + assert!(clipped.ends_with('…')); + } + + #[test] + fn conversation_thread_key_separates_hub_and_page_threads() { + assert_eq!(conversation_thread_key(Some("hub-1")), "hub-1"); + assert_eq!(conversation_thread_key(None), CURRENT_PAGE_THREAD_KEY); + // An empty id is a page-only ask, not a hub called "". + assert_eq!(conversation_thread_key(Some("")), CURRENT_PAGE_THREAD_KEY); + } + + #[test] + fn conversation_threads_persist_and_stay_bounded() { + let dir = TempDir::new(); + let path = dir.path("conversations.json"); + let paths = DataPaths { + db_path: dir.0.clone(), + library_path: dir.path("library.json"), + settings_path: dir.path("settings.json"), + icebergs_path: dir.path("icebergs.json"), + conversations_path: path.clone(), + air_exports_path: dir.0.clone(), + chunks_path: dir.path("chunks.json"), + models_path: dir.0.clone(), + exports_path: dir.0.clone(), + }; + + for index in 0..MAX_THREAD_TURNS + 5 { + block_on(append_conversation_turn( + &paths, + Some("hub-1"), + turn(&format!("q{index}"), &format!("a{index}")), + )) + .expect("append"); + } + + let thread = block_on(conversation_thread(&paths, Some("hub-1"))); + assert_eq!(thread.len(), MAX_THREAD_TURNS, "old turns should be trimmed"); + // The newest turns are the ones kept. + assert_eq!(thread.last().unwrap().prompt, format!("q{}", MAX_THREAD_TURNS + 4)); + assert_eq!(thread.first().unwrap().prompt, "q5"); + + // Threads must not leak into each other. + assert!(block_on(conversation_thread(&paths, None)).is_empty()); + } + // With no chat model the answer must be visibly a passage list, and must not claim // generation metrics it did not earn. #[test] diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 77c642c..460b8f4 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -22,6 +22,7 @@ import { FlowGraphResult, HubShortcutSummary, IcebergItem, + ConversationTurn, IcebergResult, LibraryExportResult, LibrarySearchHit, @@ -435,6 +436,7 @@ function App(): React.JSX.Element { const appliedAskContextRef = useRef(null) const [askPanelOpen, setAskPanelOpen] = useState(true) const [chatResult, setChatResult] = useState(null) + const [chatThread, setChatThread] = useState([]) const [streamingAnswer, setStreamingAnswer] = useState('') const [streamingCitations, setStreamingCitations] = useState([]) const [semanticTrailQuery, setSemanticTrailQuery] = useState('') @@ -744,6 +746,41 @@ function App(): React.JSX.Element { const clearSearch = useCallback((): void => setSearchResult(null), []) + // Thread key mirrors the backend: the selected hub, or the current-page thread when + // the ask is not scoped to a hub. + const activeThreadCollectionId = useMemo( + () => (askCurrentPageOnly ? undefined : askCollectionId || undefined), + [askCollectionId, askCurrentPageOnly] + ) + + const clearChatHistory = useCallback((): void => { + void (async () => { + try { + await window.aether.chat.clearHistory(activeThreadCollectionId) + setChatThread([]) + setChatResult(null) + } catch (error) { + setNotice(getErrorMessage(error)) + } + })() + }, [activeThreadCollectionId]) + + // Switching hub switches thread, so a stored session reappears where it was left. + useEffect(() => { + let cancelled = false + void (async () => { + try { + const thread = await window.aether.chat.history(activeThreadCollectionId) + if (!cancelled) setChatThread(thread) + } catch { + if (!cancelled) setChatThread([]) + } + })() + return () => { + cancelled = true + } + }, [activeThreadCollectionId]) + const captureLink = useCallback( async (url: string, collectionId: string): Promise => { setCapturingLink(true) @@ -1492,6 +1529,8 @@ function App(): React.JSX.Element { setChatResult(result) setAskPanelOpen(false) + // The backend persisted this turn; reload so prior turns render as a thread. + setChatThread(await window.aether.chat.history(collectionId)) } finally { askRequestIdRef.current = null if (streamFlushRef.current !== null) { @@ -2484,6 +2523,8 @@ function App(): React.JSX.Element { busy={busy} chatBlocked={chatBlocked} chatIsExtractive={chatIsExtractive} + chatThread={chatThread} + onClearHistory={clearChatHistory} chatPrompt={chatPrompt} askCollectionId={askCollectionId} askCurrentPageOnly={askCurrentPageOnly} diff --git a/src/renderer/src/assets/styles/intelligence-panel.css b/src/renderer/src/assets/styles/intelligence-panel.css index b7e8d5f..0b0338e 100644 --- a/src/renderer/src/assets/styles/intelligence-panel.css +++ b/src/renderer/src/assets/styles/intelligence-panel.css @@ -686,6 +686,60 @@ gap: 8px; } +/* Earlier turns in the active thread. Visually recessed so the newest answer stays + the focus while previous exchanges remain readable above it. */ +.thread-section { + display: grid; + gap: 10px; +} + +.thread-turn { + display: grid; + gap: 6px; + padding-left: 9px; + border-left: 2px solid rgba(75, 163, 206, 0.28); +} + +.thread-turn-prompt { + margin: 0; + font-size: 12px; + font-weight: 700; + line-height: 1.4; + color: rgba(30, 79, 112, 0.92); +} + +/* Prior answers sit back a step: same card, lower emphasis. */ +.thread-turn .answer-card { + opacity: 0.86; +} + +.thread-turn:hover .answer-card { + opacity: 1; +} + +.thread-clear-button { + padding: 3px 10px; + border: 1px solid rgba(75, 163, 206, 0.32); + border-radius: 999px; + background: rgba(255, 255, 255, 0.82); + font: inherit; + font-size: 10.5px; + font-weight: 680; + color: rgba(30, 79, 112, 0.86); + cursor: pointer; +} + +.thread-clear-button:hover { + border-color: rgba(75, 163, 206, 0.6); + background: rgba(255, 255, 255, 1); +} + +@media (prefers-reduced-motion: reduce) { + .thread-turn .answer-card { + transition: none; + } +} + /* Shown when only the embedding model is installed, so a list of passages is not read as a failed answer. */ .chat-extractive-note { diff --git a/src/renderer/src/components/IntelligencePanel.tsx b/src/renderer/src/components/IntelligencePanel.tsx index c109e34..6237e05 100644 --- a/src/renderer/src/components/IntelligencePanel.tsx +++ b/src/renderer/src/components/IntelligencePanel.tsx @@ -2,6 +2,7 @@ import { FormEvent, useEffect, useRef, useState, type RefObject, type WheelEvent import { ChatResult, CollectionSummary, + ConversationTurn, SearchResult, SemanticTrailItem, SemanticTrailResult, @@ -19,6 +20,8 @@ type IntelligencePanelProps = { chatBlocked: boolean // True when only the embedding model is installed: Ask returns passages, not prose. chatIsExtractive: boolean + // Persisted turns for the active thread, oldest first. + chatThread: ConversationTurn[] chatPrompt: string askCollectionId: string askCurrentPageOnly: boolean @@ -51,6 +54,7 @@ type IntelligencePanelProps = { onAskCurrentPageOnlyChange: (value: boolean) => void onAskIncludeCurrentPageChange: (value: boolean) => void onOpenCitation: (citation: SearchResult, claimText?: string) => Promise + onClearHistory: () => void onOpenSemanticTrailItem: (item: SemanticTrailItem) => Promise onUpdateModels: (input: { embeddingModel?: string; chatModel?: string }) => Promise } @@ -64,6 +68,7 @@ export function IntelligencePanel({ busy, chatBlocked, chatIsExtractive, + chatThread, chatPrompt, askCollectionId, askCurrentPageOnly, @@ -96,6 +101,7 @@ export function IntelligencePanel({ onAskCurrentPageOnlyChange, onAskIncludeCurrentPageChange, onOpenCitation, + onClearHistory, onOpenSemanticTrailItem, onUpdateModels }: IntelligencePanelProps): React.JSX.Element { @@ -125,6 +131,13 @@ export function IntelligencePanel({ ? !semanticTrailResult.root.url && semanticTrailResult.query.trim() === normalizedTrailQuery : Boolean(semanticTrailResult.root.url)) ) + // The newest stored turn and chatResult are the same exchange; drop it here so the + // live answer card is not duplicated above itself. + const priorTurns = + chatResult && chatThread.length > 0 && + chatThread[chatThread.length - 1].answer === chatResult.answer + ? chatThread.slice(0, -1) + : chatThread const footerStatus = busy ?? notice /* const trailBlockReason = dashboardOpen || !canUseCurrentPage ? 'Open a web page first' @@ -344,6 +357,33 @@ export function IntelligencePanel({
+ {/* Earlier turns in this thread. Rendered above the live answer so the panel + reads top-to-bottom as a conversation. */} + {priorTurns.length > 0 && ( +
+
+

Earlier

+ +
+ {priorTurns.map((turn) => ( +
+

{turn.prompt}

+ +
+ ))} +
+ )} + {busy === 'Asking ÆTHER' && (streamingAnswer ? (
diff --git a/src/renderer/src/tauri-aether.ts b/src/renderer/src/tauri-aether.ts index f4c5b5a..23781dc 100644 --- a/src/renderer/src/tauri-aether.ts +++ b/src/renderer/src/tauri-aether.ts @@ -19,6 +19,7 @@ import { ChatResult, ChatStreamEvent, CollectionSummary, + ConversationTurn, FindResult, FlowGraphResult, HubShortcutSummary, @@ -112,7 +113,10 @@ if (isTauri) { }, chat: { ask: (input) => call('aether_chat_ask', { input }), - cancel: () => call('aether_chat_cancel') + cancel: () => call('aether_chat_cancel'), + history: (collectionId) => + call('aether_chat_history', { collectionId }), + clearHistory: (collectionId) => call('aether_chat_clear_history', { collectionId }) }, crystallizer: { generate: (input) => call('aether_crystallizer_generate', { input }), diff --git a/src/shared/aether.ts b/src/shared/aether.ts index deabe3a..ae620af 100644 --- a/src/shared/aether.ts +++ b/src/shared/aether.ts @@ -250,6 +250,17 @@ export interface ChatMetrics { chunks: number } +/** One completed exchange, persisted per hub (or per current-page thread). */ +export interface ConversationTurn { + id: string + prompt: string + answer: string + model: string + askedAt: string + citations: SearchResult[] + metrics: ChatMetrics +} + export type AirLensKind = 'topic' | 'flow' | 'hub' | 'answer' | 'iceberg' export interface AirDossierInput { @@ -526,6 +537,9 @@ export interface AetherApi { requestId?: string }): Promise cancel(): Promise + // Omit collectionId for the current-page thread. + history(collectionId?: string): Promise + clearHistory(collectionId?: string): Promise } crystallizer: { generate(input: { keyword: string }): Promise From 4d2251fdaf8ee899efa0fe687a6ed9cccffd9f69 Mon Sep 17 00:00:00 2001 From: Can Ur Date: Sat, 25 Jul 2026 11:29:20 +0200 Subject: [PATCH 05/15] resizable AiON panel + audit fixes --- bun.lock | 76 +++++++------ package.json | 6 ++ src-tauri/src/lib.rs | 99 +++++++++++------ src/renderer/src/App.tsx | 100 +++++++++++++++++- src/renderer/src/assets/styles/foundation.css | 55 ++++++++++ src/renderer/src/components/MobileTabView.tsx | 2 +- .../src/components/WebContentSlot.tsx | 16 +++ src/renderer/src/tauri-aether.ts | 4 +- src/renderer/src/utils/web-content-bounds.ts | 45 ++++++++ src/shared/aether.ts | 7 +- 10 files changed, 337 insertions(+), 73 deletions(-) create mode 100644 src/renderer/src/components/WebContentSlot.tsx create mode 100644 src/renderer/src/utils/web-content-bounds.ts diff --git a/bun.lock b/bun.lock index f7dcbfe..430a666 100644 --- a/bun.lock +++ b/bun.lock @@ -6,63 +6,69 @@ "name": "aether-browser", "dependencies": { "ldrs": "^1.1.9", - "lucide-react": "^1.17.0", + "lucide-react": "^1.26.0", }, "devDependencies": { - "@eslint/js": "^9.39.4", - "@tauri-apps/api": "^2.11.0", - "@tauri-apps/cli": "^2.11.2", + "@babel/core": "^8.0.1", + "@eslint/js": "^9.39.5", + "@tauri-apps/api": "^2.11.1", + "@tauri-apps/cli": "^2.11.4", "@types/bun": "^1.3.14", - "@types/node": "^25.9.1", - "@types/react": "^19.2.15", + "@types/node": "^25.9.5", + "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.2", + "@vitejs/plugin-react": "^6.0.4", "appdmg": "^0.6.6", - "eslint": "^10.4.0", + "brace-expansion": "^5.0.8", + "eslint": "^10.8.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.2", - "prettier": "^3.8.3", - "react": "^19.2.6", - "react-dom": "^19.2.6", + "eslint-plugin-react-refresh": "^0.5.3", + "prettier": "^3.9.6", + "react": "^19.2.8", + "react-dom": "^19.2.8", "typescript": "^6.0.3", - "typescript-eslint": "^8.59.2", - "vite": "^8.0.14", + "typescript-eslint": "^8.65.0", + "vite": "^8.1.5", }, }, }, + "overrides": { + "@babel/core": "^7.29.1", + "brace-expansion": "^5.0.7", + }, "packages": { - "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], - "@babel/compat-data": ["@babel/compat-data@7.29.3", "", {}, "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg=="], + "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], - "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], + "@babel/core": ["@babel/core@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-compilation-targets": "^7.29.7", "@babel/helper-module-transforms": "^7.29.7", "@babel/helpers": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA=="], - "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], + "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], - "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], - "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], - "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="], - "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="], - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], - "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="], - "@babel/helpers": ["@babel/helpers@7.29.2", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.29.0" } }, "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw=="], + "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="], - "@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="], + "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], - "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], - "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], + "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="], - "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], @@ -224,7 +230,7 @@ "bplist-creator": ["bplist-creator@0.0.8", "", { "dependencies": { "stream-buffers": "~2.2.0" } }, "sha512-Za9JKzD6fjLC16oX2wsXfc+qBEhJBJB1YPInoAQpMLhDuj5aVOv1baGeIQSq1Fr3OCqzvsoQcSBSwGId/Ja2PA=="], - "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="], + "brace-expansion": ["brace-expansion@5.0.8", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg=="], "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": "cli.js" }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], @@ -520,10 +526,14 @@ "bun-types/@types/node": ["@types/node@25.9.1", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg=="], + "eslint-plugin-react-hooks/@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="], + "execa/cross-spawn": ["cross-spawn@6.0.6", "", { "dependencies": { "nice-try": "^1.0.4", "path-key": "^2.0.1", "semver": "^5.5.0", "shebang-command": "^1.2.0", "which": "^1.2.9" } }, "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw=="], "npm-run-path/path-key": ["path-key@2.0.1", "", {}, "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw=="], + "eslint-plugin-react-hooks/@babel/parser/@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "execa/cross-spawn/path-key": ["path-key@2.0.1", "", {}, "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw=="], "execa/cross-spawn/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="], @@ -532,6 +542,10 @@ "execa/cross-spawn/which": ["which@1.3.1", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "which": "./bin/which" } }, "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ=="], + "eslint-plugin-react-hooks/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "eslint-plugin-react-hooks/@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "execa/cross-spawn/shebang-command/shebang-regex": ["shebang-regex@1.0.0", "", {}, "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ=="], } } diff --git a/package.json b/package.json index 5e3f705..b7179ab 100644 --- a/package.json +++ b/package.json @@ -44,11 +44,17 @@ "build:desktop-local": "bun run build && bun run linux:arm64:build && bun run linux:x64:build", "tauri": "tauri" }, + "overrides": { + "brace-expansion": "^5.0.7", + "@babel/core": "^7.29.1" + }, "dependencies": { "ldrs": "^1.1.9", "lucide-react": "^1.26.0" }, "devDependencies": { + "@babel/core": "^8.0.1", + "brace-expansion": "^5.0.8", "@eslint/js": "^9.39.5", "@tauri-apps/api": "^2.11.1", "@tauri-apps/cli": "^2.11.4", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7a1e4fb..0740633 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -215,10 +215,10 @@ struct Backend { tabs: Mutex, #[cfg(desktop)] webviews: Mutex, - // Where the renderer wants Android tab WebViews placed, in CSS pixels - // (reported by MobileTabView via aether_layout_set_mobile_tab_bounds). - #[cfg(not(desktop))] - mobile_tab_bounds: Mutex, + // Where the renderer wants live web content placed, in CSS pixels, reported via + // aether_layout_set_web_content_bounds. Both shells use it; on desktop it takes + // precedence over the SIDEBAR_WIDTH/BROWSER_VIEW_TOP/PANEL_WIDTH constants. + web_content_bounds: Mutex, client: Client, native_runtime: Arc>, vectors: tokio::sync::RwLock>, @@ -231,9 +231,12 @@ struct NativeBrowserViews { views: HashMap, } -#[cfg(not(desktop))] -#[derive(Clone, Copy, Default)] -struct MobileTabBounds { +// Where live web content belongs inside the window, in CSS px, as measured by the +// renderer. Both shells report the same rect: Android positions native WebViews with +// it, desktop positions its child webviews with it. Measuring beats hardcoding, +// because the chrome that defines these edges is owned by CSS. +#[derive(Clone, Copy, Default, PartialEq)] +struct WebContentBounds { top: f64, left: f64, width: f64, @@ -1446,8 +1449,7 @@ impl Backend { tabs: Mutex::new(TabState::new()), #[cfg(desktop)] webviews: Mutex::new(NativeBrowserViews::default()), - #[cfg(not(desktop))] - mobile_tab_bounds: Mutex::new(MobileTabBounds::default()), + web_content_bounds: Mutex::new(WebContentBounds::default()), client: Client::builder() .user_agent("Aether/1.0 Tauri") .build() @@ -2423,10 +2425,17 @@ fn sync_native_webview_visibility(app: &AppHandle, state: &State) -> Cm tabs.panel_collapsed, ) }; - let window = app - .get_window("main") - .ok_or_else(|| "Æther main window is not ready.".to_string())?; - let bounds = native_webview_bounds_for_window(&window, panel_collapsed)?; + // Prefer the renderer-measured slot; fall back to the layout constants until the + // first report arrives. + let bounds = match reported_webview_bounds(state) { + Some(bounds) => bounds, + None => { + let window = app + .get_window("main") + .ok_or_else(|| "Æther main window is not ready.".to_string())?; + native_webview_bounds_for_window(&window, panel_collapsed)? + } + }; let webviews = state .webviews .lock() @@ -2464,7 +2473,7 @@ fn sync_native_webview_visibility(app: &AppHandle, state: &State) -> Cm ) }; let bounds = *state - .mobile_tab_bounds + .web_content_bounds .lock() .map_err(|_| "Æther layout bounds are unavailable.".to_string())?; return app.state::().run( @@ -2487,6 +2496,9 @@ fn sync_native_webview_visibility(app: &AppHandle, state: &State) -> Cm #[cfg(desktop)] fn native_webview_bounds(window: &Window, state: &State) -> Cmd { + if let Some(bounds) = reported_webview_bounds(state) { + return Ok(bounds); + } let panel_collapsed = lock_tabs(state)?.panel_collapsed; native_webview_bounds_for_window(window, panel_collapsed) } @@ -2511,6 +2523,24 @@ fn native_webview_bounds_for_window(window: &Window, panel_collapsed: bool) -> C }) } +// Preferred over the constants above: the renderer measures the actual content slot, +// so the chrome's real height and the panel's real width define the web view instead +// of numbers that silently drift whenever the CSS changes. The constants remain the +// fallback for the first frames, before the renderer has reported anything. +#[cfg(desktop)] +fn reported_webview_bounds(state: &State) -> Option { + let bounds = *state.web_content_bounds.lock().ok()?; + // A zero-size rect means the content slot is not laid out (dashboard open, or the + // very first frame); positioning a webview to it would collapse the view. + if bounds.width < 1.0 || bounds.height < 1.0 { + return None; + } + Some(Rect { + position: Position::Logical(LogicalPosition::new(bounds.left, bounds.top)), + size: Size::Logical(LogicalSize::new(bounds.width, bounds.height)), + }) +} + #[cfg(desktop)] fn native_webview_label(tab_id: &str) -> String { format!("aether-browser-tab-{tab_id}") @@ -2966,7 +2996,7 @@ pub fn run() { aether_tabs_report_native_event, aether_tabs_thumbnail, aether_layout_window_insets, - aether_layout_set_mobile_tab_bounds, + aether_layout_set_web_content_bounds, aether_dashboard_open, aether_hub_list, aether_hub_create, @@ -3446,10 +3476,12 @@ async fn aether_layout_window_insets(app: AppHandle) -> Cmd { } // The renderer measures where Android tab WebViews belong (MobileTabView's -// bounding rect, CSS px) and reports it here; desktop computes bounds natively -// from the window size instead, so this is a no-op there. +// The renderer measures the slot where live web content belongs (a placeholder div's +// bounding rect, CSS px) and reports it here. Both shells position their native web +// views from this, so a chrome restyle or a panel resize moves the content with it +// instead of drifting away from hardcoded offsets. #[tauri::command(rename_all = "camelCase")] -fn aether_layout_set_mobile_tab_bounds( +fn aether_layout_set_web_content_bounds( app: AppHandle, state: State, top: f64, @@ -3457,24 +3489,25 @@ fn aether_layout_set_mobile_tab_bounds( width: f64, height: f64, ) -> Cmd<()> { - #[cfg(not(desktop))] + let next = WebContentBounds { + top, + left, + width, + height, + }; { - *state - .mobile_tab_bounds + let mut stored = state + .web_content_bounds .lock() - .map_err(|_| "Æther layout bounds are unavailable.".to_string())? = MobileTabBounds { - top, - left, - width, - height, - }; - return sync_native_webview_visibility(&app, &state); - } - #[allow(unreachable_code)] - { - let _ = (app, state, top, left, width, height); - Ok(()) + .map_err(|_| "Æther layout bounds are unavailable.".to_string())?; + // A ResizeObserver fires on every layout pass; repositioning native webviews + // for an unchanged rect causes visible flicker on desktop. + if *stored == next { + return Ok(()); + } + *stored = next; } + sync_native_webview_visibility(&app, &state) } #[tauri::command] diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 460b8f4..5e98f89 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,4 +1,13 @@ -import { FormEvent, RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + CSSProperties, + FormEvent, + RefObject, + useCallback, + useEffect, + useMemo, + useRef, + useState +} from 'react' import { addPluginListener, invoke, type PluginListener } from '@tauri-apps/api/core' import packageManifest from '../../../package.json' import { @@ -42,6 +51,7 @@ import { } from '../../shared/aether' import { BrowserChrome } from './components/BrowserChrome' import { MobileShell } from './components/MobileShell' +import { WebContentSlot } from './components/WebContentSlot' import { MobileTabView } from './components/MobileTabView' import { StartPage } from './components/StartPage' import { CollectionDialog, CollectionDialogState } from './components/CollectionDialog' @@ -80,6 +90,24 @@ import { // Sentinel URL for a blank tab that shows the ÆTHER start page instead of loading a // page. Must match START_PAGE_URL in src-tauri/src/lib.rs. const START_PAGE_URL = 'aether://start' +// AiON panel sizing. Must stay in step with --panel-collapsed-width in foundation.css +// and PANEL_COLLAPSED_WIDTH in src-tauri/src/lib.rs. +const PANEL_COLLAPSED_WIDTH = 58 +const PANEL_MIN_WIDTH = 320 +const PANEL_MAX_WIDTH = 720 +const PANEL_DEFAULT_WIDTH = 404 +const PANEL_WIDTH_STORAGE_KEY = 'aether:panel-width' + +function clampPanelWidth(value: number): number { + return Math.round(Math.min(PANEL_MAX_WIDTH, Math.max(PANEL_MIN_WIDTH, value))) +} + +// Panel width is pure window chrome, so it lives in localStorage rather than in the +// synced settings store. +function readStoredPanelWidth(): number { + const stored = Number(window.localStorage.getItem(PANEL_WIDTH_STORAGE_KEY)) + return Number.isFinite(stored) && stored > 0 ? clampPanelWidth(stored) : PANEL_DEFAULT_WIDTH +} const APP_VERSION = packageManifest.version const DASHBOARD_ADDRESSES = new Set([ 'æther://dashboard', @@ -414,6 +442,10 @@ function App(): React.JSX.Element { }) const [updateCheck, setUpdateCheck] = useState(null) const [updateChecking, setUpdateChecking] = useState(false) + const [panelWidth, setPanelWidth] = useState(readStoredPanelWidth) + const [panelResizing, setPanelResizing] = useState(false) + // Pointer handlers read the committed width synchronously mid-drag. + const panelWidthRef = useRef(panelWidth) const [capturingLink, setCapturingLink] = useState(false) const [searching, setSearching] = useState(false) const [searchResult, setSearchResult] = useState(null) @@ -746,6 +778,44 @@ function App(): React.JSX.Element { const clearSearch = useCallback((): void => setSearchResult(null), []) + // Pointer-driven panel resize. Width is committed to state on every move so the + // grid, the measured content slot, and the native webview stay in step. + const startPanelResize = useCallback((event: React.PointerEvent): void => { + event.preventDefault() + setPanelResizing(true) + const startX = event.clientX + const startWidth = panelWidthRef.current + + const onMove = (move: PointerEvent): void => { + // Dragging left widens the panel, so the delta is inverted. + const next = clampPanelWidth(startWidth + (startX - move.clientX)) + panelWidthRef.current = next + setPanelWidth(next) + } + const onUp = (): void => { + setPanelResizing(false) + window.removeEventListener('pointermove', onMove) + window.removeEventListener('pointerup', onUp) + window.localStorage.setItem(PANEL_WIDTH_STORAGE_KEY, String(panelWidthRef.current)) + } + + window.addEventListener('pointermove', onMove) + window.addEventListener('pointerup', onUp) + }, []) + + // Keyboard equivalent: a drag-only control is unusable without a pointer. + const handlePanelResizeKey = useCallback((event: React.KeyboardEvent): void => { + const step = event.shiftKey ? 40 : 12 + const delta = + event.key === 'ArrowLeft' ? step : event.key === 'ArrowRight' ? -step : 0 + if (delta === 0) return + event.preventDefault() + const next = clampPanelWidth(panelWidthRef.current + delta) + panelWidthRef.current = next + setPanelWidth(next) + window.localStorage.setItem(PANEL_WIDTH_STORAGE_KEY, String(next)) + }, []) + // Thread key mirrors the backend: the selected hub, or the current-page thread when // the ask is not scoped to a hub. const activeThreadCollectionId = useMemo( @@ -2312,7 +2382,14 @@ function App(): React.JSX.Element { } return ( -
+
{toast && } {findBarNode}
+ {/* Drag handle for the AiON panel. Sits between the content slot and the panel + so the pointer target is the seam the user is actually grabbing. */} + {!panelCollapsed && ( +
+ )} + { const rect = host.getBoundingClientRect() void window.aether.layout - .setMobileTabBounds({ + .setWebContentBounds({ top: rect.top, left: rect.left, width: rect.width, diff --git a/src/renderer/src/components/WebContentSlot.tsx b/src/renderer/src/components/WebContentSlot.tsx new file mode 100644 index 0000000..a68b828 --- /dev/null +++ b/src/renderer/src/components/WebContentSlot.tsx @@ -0,0 +1,16 @@ +import { useRef } from 'react' +import { useWebContentBounds } from '../utils/web-content-bounds' + +// Desktop counterpart to MobileTabView. Tauri child webviews are native and draw over +// the DOM, so this div is not the content — it is the measured hole the content is +// positioned into. Replacing the old SIDEBAR_WIDTH / BROWSER_VIEW_TOP / PANEL_WIDTH +// constants with a measurement means the chrome's real CSS geometry decides where web +// pages sit. +export function WebContentSlot({ panelWidth }: { panelWidth: number }): React.JSX.Element { + const hostRef = useRef(null) + // Panel width is a dependency because dragging the panel moves this slot's right + // edge, and a re-measure has to follow the committed layout. + useWebContentBounds(hostRef, [panelWidth]) + + return + {indexStatus && indexStatus.pendingReembed > 0 && ( +
+
+ + + {indexStatus.pendingReembed} passages need re-indexing + + These were embedded by a different model, so search cannot compare them. + Their text is kept, so re-indexing runs locally without refetching any + page. + + +
+ +
+ )} +
diff --git a/src/renderer/src/assets/styles/setup-settings.css b/src/renderer/src/assets/styles/setup-settings.css index 45ef903..edc0c5f 100644 --- a/src/renderer/src/assets/styles/setup-settings.css +++ b/src/renderer/src/assets/styles/setup-settings.css @@ -1007,6 +1007,28 @@ line-height: 1.35; } +/* An attention state, not an error: the library still works, it is just smaller than + it should be until the re-index runs. Scoped rather than a global token, since the + palette-wide tokenization is a separate piece of work. */ +.settings-reindex-field { + --warning-strong: #b4690e; + border: 1px solid rgba(180, 105, 14, 0.28); + border-radius: 12px; + padding: 12px 14px; + background: rgba(180, 105, 14, 0.06); +} + +.settings-reindex-field .settings-model-setup-copy svg { + color: var(--warning-strong); +} + +/* The explanatory copy is longer than the sibling rows', which squeezes the button + until its label wraps. The label is the action, so the text reflows instead. */ +.settings-reindex-field .model-setup-button { + flex: 0 0 auto; + white-space: nowrap; +} + .settings-update-field { display: grid; gap: 12px; diff --git a/src/renderer/src/tauri-aether.ts b/src/renderer/src/tauri-aether.ts index 95d700e..8a9e659 100644 --- a/src/renderer/src/tauri-aether.ts +++ b/src/renderer/src/tauri-aether.ts @@ -26,6 +26,8 @@ import { HubShortcutSummary, IcebergResult, LibraryExportResult, + LibraryIndexStatus, + LibraryReindexResult, LibrarySearchResult, MOBILE_TAB_SCROLL_EVENT, ModelDownloadProgress, @@ -135,6 +137,8 @@ if (isTauri) { updateModels: (input) => call('aether_system_update_models', { input }), checkForUpdate: () => call('aether_system_check_for_update'), exportLibrary: () => call('aether_system_export_library'), + indexStatus: () => call('aether_library_index_status'), + reindexLibrary: () => call('aether_library_reindex'), openExternalUrl: (url) => call('aether_system_open_external_url', { url }), downloadModels: (input) => call('aether_system_download_models', { input }) }, diff --git a/src/shared/aether.ts b/src/shared/aether.ts index 91a7bfa..f3bab16 100644 --- a/src/shared/aether.ts +++ b/src/shared/aether.ts @@ -399,6 +399,21 @@ export interface LibraryExportResult { byteSize: number } +export interface LibraryIndexStatus { + /** Embedding width the store is built around. 0 before anything is indexed. */ + dim: number + embedded: number + /** Chunks whose text is kept but whose vector is unusable until a re-index. */ + pendingReembed: number +} + +export interface LibraryReindexResult { + embedded: number + stillPending: number + dim: number + reindexedAt: string +} + export interface UpdateCheckResult { currentVersion: string checkedAt: string @@ -565,6 +580,11 @@ export interface AetherApi { checkForUpdate(): Promise // Snapshots every local store into a timestamped folder and reveals it. exportLibrary(): Promise + // Loads the vector store, so Settings asks for this on open rather than at startup. + indexStatus(): Promise + // Re-embeds retained chunk text with the loaded model. The only way to recover + // chunks embedded by a previous model, whose widths cannot be compared. + reindexLibrary(): Promise openExternalUrl(url: string): Promise downloadModels(input: { chatModels: ModelDownloadChoice[] From 9aaecd9d9a3a1332d9f74c44e286cc43c2e0717a Mon Sep 17 00:00:00 2001 From: Can Ur Date: Sat, 25 Jul 2026 13:34:58 +0200 Subject: [PATCH 09/15] Polish --- src/renderer/src/App.tsx | 27 ++-- .../src/assets/styles/intelligence-panel.css | 127 +++++++++++++++++- .../src/assets/styles/setup-settings.css | 3 + .../src/components/IntelligencePanel.tsx | 84 +++++++++--- .../src/components/ModelSetupModal.tsx | 13 +- src/renderer/src/utils/dismissable-overlay.ts | 56 ++++++++ 6 files changed, 277 insertions(+), 33 deletions(-) create mode 100644 src/renderer/src/utils/dismissable-overlay.ts diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index efbc53d..995d65d 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -72,6 +72,7 @@ import { normalizeComparableUrl } from './utils/aether-ui' import { HAS_NATIVE_TAB_WEBVIEWS, IS_ANDROID } from './utils/platform' +import { useDismissableOverlay } from './utils/dismissable-overlay' import { ChevronDown, ChevronUp, @@ -1231,7 +1232,7 @@ function App(): React.JSX.Element { await window.aether.layout.setModalOverlayOpen(Boolean(settingsOpen || collectionDialog)) } - async function openModelSetupFromSettings(): Promise { + async function openModelSetup(): Promise { setSettingsOpen(false) setModelSetupDismissed(false) setModelSetupRequested(true) @@ -2344,7 +2345,7 @@ function App(): React.JSX.Element { onCheckForUpdates={() => checkForUpdates()} onOpenUpdateRelease={openUpdateRelease} onUpdateAutoCheck={updateAutoCheck} - onOpenModelSetup={openModelSetupFromSettings} + onOpenModelSetup={openModelSetup} /> )} @@ -2391,7 +2392,7 @@ function App(): React.JSX.Element { onCancel: cancelAsk, onChatPromptChange: setChatPrompt, onOpenCitation: openCitation, - onOpenModelSetup: openModelSetupFromSettings + onOpenModelSetup: openModelSetup }} backInterceptorRef={mobileBackInterceptorRef} openAionRef={mobileOpenAionRef} @@ -2714,6 +2715,7 @@ function App(): React.JSX.Element { onAskCurrentPageOnlyChange={setAskCurrentPageOnly} onAskIncludeCurrentPageChange={setAskIncludeCurrentPage} onUpdateModels={updateLocalModels} + onOpenModelSetup={openModelSetup} onOpenCitation={openCitation} onOpenSemanticTrailItem={openSemanticTrailItem} /> @@ -2896,6 +2898,14 @@ function SettingsModal({ onUpdateAutoCheck: (value: boolean) => Promise onOpenModelSetup: () => Promise }): React.JSX.Element { + // Suppressed while a library export or re-index is running, so a stray click or + // Escape cannot close the panel out from under work the user is watching. + const settingsBackdrop = useDismissableOverlay( + () => { + void onClose() + }, + !exportingLibrary && !reindexing + ) const installedVersion = updateCheck?.currentVersion ?? APP_VERSION const searchEngines: Array<{ id: SearchEngineId; name: string; description: string }> = [ { id: 'google', name: 'Google', description: 'Broad default web search.' }, @@ -2906,19 +2916,14 @@ function SettingsModal({ ] return ( -
{ - void onClose() - }} - role="presentation" - > +
+ {/* No stopPropagation on the card: the backdrop dismisses on + `target === currentTarget`, so descendants are inert by construction. */}
event.stopPropagation()} >
diff --git a/src/renderer/src/assets/styles/intelligence-panel.css b/src/renderer/src/assets/styles/intelligence-panel.css index 0b0338e..f0463cf 100644 --- a/src/renderer/src/assets/styles/intelligence-panel.css +++ b/src/renderer/src/assets/styles/intelligence-panel.css @@ -1482,10 +1482,19 @@ white-space: normal; } -.inline-model-selector { - display: grid; +/* The selector no longer owns the footer's second column on its own — it shares it + with the setup button, so the wrapper takes the grid placement. */ +.inline-model-controls { + display: flex; grid-column: 2; justify-self: end; + min-width: 0; + align-items: center; + gap: 6px; +} + +.inline-model-selector { + display: grid; width: min(180px, 100%); grid-template-columns: auto minmax(0, 1fr); align-items: center; @@ -1549,6 +1558,120 @@ box-shadow: 0 0 0 3px rgba(106, 193, 229, 0.18); } +/* Icon-only sibling of the model selector. Sized to the selector's own pill so the + pair reads as one control group rather than a control plus an afterthought. */ +/* The icons are flex items; without an explicit shrink guard they collapse to zero + width and the button renders as an empty pill. */ +.inline-model-manage-button svg, +.inline-model-setup-button svg, +.model-island-setup-button svg { + flex: 0 0 auto; +} + +.inline-model-manage-button { + display: inline-flex; + box-sizing: border-box; + flex: 0 0 auto; + width: 28px; + height: 28px; + align-items: center; + justify-content: center; + /* A base button rule contributes padding; without this the pill is not round. */ + padding: 0; + border: 1px solid rgba(105, 188, 224, 0.22); + border-radius: 999px; + background: + radial-gradient(circle at 18% 16%, rgba(255, 255, 255, 0.95), transparent 42%), + linear-gradient(145deg, rgba(241, 252, 255, 0.84), rgba(255, 255, 255, 0.6)); + color: #2a7a9f; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.72), + 0 8px 18px rgba(74, 145, 194, 0.08); + cursor: pointer; + transition: + border-color 0.2s ease, + box-shadow 0.2s ease; +} + +.inline-model-manage-button:hover:not(:disabled) { + border-color: rgba(142, 219, 255, 0.6); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.9), + 0 8px 24px rgba(105, 188, 224, 0.2); +} + +.inline-model-manage-button:disabled { + opacity: 0.5; + cursor: default; +} + +/* Replaces the selector outright when no model is installed, so the footer slot is an + action instead of a disabled dropdown. Deliberately labelled, not icon-only: this is + the state where the user has to be told what to do next. */ +.inline-model-setup-button { + display: inline-flex; + grid-column: 2; + justify-self: end; + align-items: center; + gap: 6px; + height: 28px; + border: 1px solid rgba(105, 188, 224, 0.35); + border-radius: 999px; + padding: 0 12px; + background: linear-gradient(145deg, rgba(226, 246, 255, 0.95), rgba(255, 255, 255, 0.7)); + color: #1f6c92; + font-size: 11px; + font-weight: 860; + white-space: nowrap; + cursor: pointer; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.8), + 0 8px 18px rgba(74, 145, 194, 0.12); + transition: + border-color 0.2s ease, + box-shadow 0.2s ease; +} + +.inline-model-setup-button:hover:not(:disabled) { + border-color: rgba(142, 219, 255, 0.7); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.9), + 0 8px 24px rgba(105, 188, 224, 0.24); +} + +.inline-model-setup-button:disabled { + opacity: 0.55; + cursor: default; +} + +/* Developer-mode "Built-in Models" island. */ +.model-island-setup-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + width: 100%; + height: 30px; + margin-top: 4px; + border: 1px solid rgba(105, 188, 224, 0.35); + border-radius: 10px; + background: linear-gradient(145deg, rgba(226, 246, 255, 0.9), rgba(255, 255, 255, 0.68)); + color: #1f6c92; + font-size: 11px; + font-weight: 800; + cursor: pointer; + transition: border-color 0.2s ease; +} + +.model-island-setup-button:hover:not(:disabled) { + border-color: rgba(142, 219, 255, 0.7); +} + +.model-island-setup-button:disabled { + opacity: 0.55; + cursor: default; +} + .model-settings-button { display: inline-flex; grid-column: 2; diff --git a/src/renderer/src/assets/styles/setup-settings.css b/src/renderer/src/assets/styles/setup-settings.css index edc0c5f..e31ba03 100644 --- a/src/renderer/src/assets/styles/setup-settings.css +++ b/src/renderer/src/assets/styles/setup-settings.css @@ -40,6 +40,9 @@ position: relative; display: grid; gap: 14px; + /* Without this the 18px padding and 1px border sit outside the shell's width, so the + card overhangs its own wrapper and eats into the backdrop gutter on the right. */ + box-sizing: border-box; width: 100%; max-height: calc(100vh - 150px); overflow-x: hidden; diff --git a/src/renderer/src/components/IntelligencePanel.tsx b/src/renderer/src/components/IntelligencePanel.tsx index 6237e05..71bfe54 100644 --- a/src/renderer/src/components/IntelligencePanel.tsx +++ b/src/renderer/src/components/IntelligencePanel.tsx @@ -13,7 +13,7 @@ import { formatDate, formatVisibleModelName, getCaptureHost } from '../utils/aet import { claimTextForCitation, renderAnswerMarkdown } from './answer-markdown' import { CrystallizingOrb } from './CrystallizingOrb' import { AetherSigilIcon, ChevronRightIcon, GearIcon } from './icons' -import { Droplet, Waves, Newspaper } from 'lucide-react' +import { Droplet, HardDriveDownload, Waves, Newspaper } from 'lucide-react' type IntelligencePanelProps = { busy: string | null @@ -57,6 +57,7 @@ type IntelligencePanelProps = { onClearHistory: () => void onOpenSemanticTrailItem: (item: SemanticTrailItem) => Promise onUpdateModels: (input: { embeddingModel?: string; chatModel?: string }) => Promise + onOpenModelSetup: () => Promise } function modelOptionsWithSelected(models: string[], selected?: string | null): string[] { @@ -103,7 +104,8 @@ export function IntelligencePanel({ onOpenCitation, onClearHistory, onOpenSemanticTrailItem, - onUpdateModels + onUpdateModels, + onOpenModelSetup }: IntelligencePanelProps): React.JSX.Element { const [settingsOpen, setSettingsOpen] = useState(false) const [trailPanelOpen, setTrailPanelOpen] = useState(false) @@ -529,24 +531,54 @@ export function IntelligencePanel({ : 'Model settings'} + ) : chatModelOptions.length === 0 ? ( + // With nothing installed the select is a dead control, so the slot carries + // the action that resolves it instead. This is the only route to setup from + // the panel where a missing model actually shows up. + ) : ( - + {chatModelOptions.map((model) => ( + + ))} + + + {/* Outside the label: nested in it, a click would also drive the select. */} + +
)} {developerMode && settingsOpen && ( @@ -556,6 +588,7 @@ export function IntelligencePanel({ settingsRef={modelSettingsRef} status={status} onUpdateModels={onUpdateModels} + onOpenModelSetup={onOpenModelSetup} /> )}
@@ -818,13 +851,15 @@ function LocalModelSettings({ developerMode, settingsRef, status, - onUpdateModels + onUpdateModels, + onOpenModelSetup }: { busy: string | null developerMode: boolean settingsRef: RefObject status: SystemStatus | null onUpdateModels: (input: { embeddingModel?: string; chatModel?: string }) => Promise + onOpenModelSetup: () => Promise }): React.JSX.Element { const models = status?.availableModels ?? [] const chatModels = modelOptionsWithSelected(status?.chatModels ?? [], status?.chatModel) @@ -906,6 +941,17 @@ function LocalModelSettings({ ))} + ) } diff --git a/src/renderer/src/components/ModelSetupModal.tsx b/src/renderer/src/components/ModelSetupModal.tsx index 2dd1407..f801e3a 100644 --- a/src/renderer/src/components/ModelSetupModal.tsx +++ b/src/renderer/src/components/ModelSetupModal.tsx @@ -12,6 +12,7 @@ import 'ldrs/react/LineWobble.css' import 'ldrs/react/Quantum.css' import 'ldrs/react/Ripples.css' import { ModelDownloadChoice, ModelDownloadProgress } from '../../../shared/aether' +import { useDismissableOverlay } from '../utils/dismissable-overlay' const MODEL_SETUP_OPTIONS: Array<{ id: ModelDownloadChoice @@ -121,6 +122,10 @@ export function ModelSetupModal({ (max, item) => Math.max(max, item.overallDownloadedBytes), 0 ) + // `busy` is an install in flight: neither a backdrop click nor Escape should abandon + // a download. `onClose` already no-ops in that state; this stops the attempt earlier. + const backdrop = useDismissableOverlay(onClose, !busy) + const overallPercent = complete ? 100 : progressPercent(progressDownloaded, progressTotal) const hasNewChatSelection = selectedModels.some((model) => !installedModels.includes(model)) const canStart = (hasNewChatSelection || !coreInstalled) && !busy && !complete @@ -136,7 +141,13 @@ export function ModelSetupModal({ 'Install MiST only · 639 MB' return ( -
+
+ {/* The shell wraps both the card and the actions below it, so anything inside — + including the Later/Install buttons — is a descendant and never dismisses. */}
) => void + onClick: (event: MouseEvent) => void +} + +// Shared dismiss behaviour for the full-screen modals: click the backdrop or press +// Escape to close. Spread the returned handlers onto the backdrop element itself — the +// dismiss test is `target === currentTarget`, so every descendant is inert by +// construction and no modal has to enumerate the regions that must not close. +// +// `dismissable: false` (a running install, say) suppresses both routes, so a modal that +// must not be abandoned cannot be dismissed by an errant click or keypress. +export function useDismissableOverlay( + onDismiss: () => void, + dismissable = true +): DismissableOverlay { + // A click only counts when the gesture both started and ended on the backdrop. + // Selecting text inside the card and releasing outside it fires a click whose target + // is the backdrop; without this the modal would close mid-drag. + const pressedBackdrop = useRef(false) + // Callers pass inline closures, so keeping the callback in a ref stops the Escape + // listener from being torn down and re-added on every parent render. + const dismissRef = useRef(onDismiss) + dismissRef.current = onDismiss + + useEffect(() => { + if (!dismissable) return undefined + + function handleKeyDown(event: KeyboardEvent): void { + if (event.key !== 'Escape' || event.defaultPrevented) return + event.preventDefault() + dismissRef.current() + } + + // Capture phase, so the modal wins over any Escape handling further down the tree. + document.addEventListener('keydown', handleKeyDown, true) + return () => document.removeEventListener('keydown', handleKeyDown, true) + }, [dismissable]) + + function isBackdrop(event: SyntheticEvent): boolean { + return event.target === event.currentTarget + } + + return { + onPointerDown: (event) => { + pressedBackdrop.current = isBackdrop(event) + }, + onClick: (event) => { + const dismissed = pressedBackdrop.current && isBackdrop(event) + pressedBackdrop.current = false + if (dismissed && dismissable) onDismiss() + } + } +} From b12081ada9f6cf5902c1a7cbab8550cf85de964b Mon Sep 17 00:00:00 2001 From: Can Ur Date: Sat, 25 Jul 2026 18:00:37 +0200 Subject: [PATCH 10/15] giant audit --- .github/actions/updater-flags/action.yml | 35 + .github/workflows/build.yml | 81 +- README.md | 57 +- docs/LICENSING.md | 123 + docs/SECURITY.md | 93 + docs/SIGNING.md | 195 + scripts/updater-manifest.sh | 75 + src-tauri/Cargo.lock | 277 +- src-tauri/Cargo.toml | 6 + src-tauri/src/air.rs | 791 ++ src-tauri/src/chat.rs | 319 + src-tauri/src/commands.rs | 2014 +++ src-tauri/src/diagnostics.rs | 189 + src-tauri/src/extract.rs | 208 + src-tauri/src/flow.rs | 285 + src-tauri/src/iceberg.rs | 513 + src-tauri/src/inference.rs | 681 + src-tauri/src/lib.rs | 11303 ++-------------- src-tauri/src/model_catalog.rs | 455 + src-tauri/src/model_downloads.rs | 380 + src-tauri/src/retrieval.rs | 457 + src-tauri/src/retrieval_scoring.rs | 193 + src-tauri/src/store.rs | 131 + src-tauri/src/system.rs | 391 + src-tauri/src/trail.rs | 217 + src-tauri/src/types.rs | 1383 ++ src-tauri/src/util.rs | 240 + src-tauri/src/vectors.rs | 300 + src-tauri/src/webview.rs | 1170 ++ src-tauri/tauri.conf.json | 44 +- src-tauri/tauri.updater.conf.json | 6 + src/renderer/index.html | 10 +- src/renderer/src/App.tsx | 543 +- src/renderer/src/assets/styles/air-view.css | 112 +- .../src/assets/styles/browser-chrome.css | 136 +- .../src/assets/styles/crystallizer.css | 509 +- .../src/assets/styles/dashboard-captures.css | 298 +- src/renderer/src/assets/styles/flow-map.css | 168 +- src/renderer/src/assets/styles/flow-view.css | 10 +- src/renderer/src/assets/styles/foundation.css | 571 +- .../src/assets/styles/intelligence-panel.css | 376 +- .../src/assets/styles/knowledge-shelves.css | 170 +- .../src/assets/styles/mobile-shell.css | 98 +- .../src/assets/styles/setup-settings.css | 427 +- .../src/assets/styles/shared-overrides.css | 142 +- src/renderer/src/assets/styles/start-page.css | 60 +- src/renderer/src/components/AirView.tsx | 12 +- src/renderer/src/components/BrowserChrome.tsx | 6 +- .../src/components/CaptureDetailCard.tsx | 4 +- .../src/components/CollectionDialog.tsx | 2 +- src/renderer/src/components/Crystallizer.tsx | 190 +- src/renderer/src/components/Dashboard.tsx | 30 +- src/renderer/src/components/FlowView.tsx | 13 +- .../src/components/IntelligencePanel.tsx | 8 +- src/renderer/src/components/MobileShell.tsx | 6 +- src/renderer/src/tauri-aether.ts | 20 +- src/renderer/src/utils/aether-ui.ts | 38 +- src/shared/aether.ts | 55 +- 58 files changed, 14947 insertions(+), 11679 deletions(-) create mode 100644 .github/actions/updater-flags/action.yml create mode 100644 docs/LICENSING.md create mode 100644 docs/SECURITY.md create mode 100644 docs/SIGNING.md create mode 100755 scripts/updater-manifest.sh create mode 100644 src-tauri/src/air.rs create mode 100644 src-tauri/src/chat.rs create mode 100644 src-tauri/src/commands.rs create mode 100644 src-tauri/src/diagnostics.rs create mode 100644 src-tauri/src/extract.rs create mode 100644 src-tauri/src/flow.rs create mode 100644 src-tauri/src/iceberg.rs create mode 100644 src-tauri/src/inference.rs create mode 100644 src-tauri/src/model_catalog.rs create mode 100644 src-tauri/src/model_downloads.rs create mode 100644 src-tauri/src/retrieval.rs create mode 100644 src-tauri/src/retrieval_scoring.rs create mode 100644 src-tauri/src/store.rs create mode 100644 src-tauri/src/system.rs create mode 100644 src-tauri/src/trail.rs create mode 100644 src-tauri/src/types.rs create mode 100644 src-tauri/src/util.rs create mode 100644 src-tauri/src/vectors.rs create mode 100644 src-tauri/src/webview.rs create mode 100644 src-tauri/tauri.updater.conf.json diff --git a/.github/actions/updater-flags/action.yml b/.github/actions/updater-flags/action.yml new file mode 100644 index 0000000..65e35eb --- /dev/null +++ b/.github/actions/updater-flags/action.yml @@ -0,0 +1,35 @@ +name: Resolve updater signing +description: >- + Decides whether this build should produce signed in-app updater artifacts. + `bundle.createUpdaterArtifacts` is a hard error when TAURI_SIGNING_PRIVATE_KEY + is missing, so it lives in tauri.updater.conf.json and is only merged in when + the secret actually exists. Without it, builds are byte-identical to before and + the app reports the `unconfigured` update status instead of offering a download + it could never verify. + +outputs: + config: + description: >- + Either the `--config ` flag to append to `tauri build`, or empty. + value: ${{ steps.resolve.outputs.config }} + signed: + description: '"true" when updater artifacts will be produced.' + value: ${{ steps.resolve.outputs.signed }} + +runs: + using: composite + steps: + - id: resolve + shell: bash + run: | + set -euo pipefail + + if [[ -n "${TAURI_SIGNING_PRIVATE_KEY:-}" ]]; then + echo 'config=--config src-tauri/tauri.updater.conf.json' >> "$GITHUB_OUTPUT" + echo 'signed=true' >> "$GITHUB_OUTPUT" + echo "Updater artifacts enabled for this build." + else + echo 'config=' >> "$GITHUB_OUTPUT" + echo 'signed=false' >> "$GITHUB_OUTPUT" + echo "::notice title=No updater signing key::TAURI_SIGNING_PRIVATE_KEY is not set, so this build ships without in-app update artifacts. Generate a keypair per docs/SIGNING.md to enable them." + fi diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0a6e77f..db5b493 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,6 +25,11 @@ env: CARGO_HTTP_MULTIPLEXING: 'false' CARGO_HTTP_TIMEOUT: '60' CARGO_NET_RETRY: '10' + # Signs the in-app updater artifacts (minisign; unrelated to OS code signing). + # Absent until the keypair exists, and every job below degrades to a plain + # unsigned build when it is — see the "Resolve updater signing" steps. + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} jobs: macos: @@ -40,7 +45,16 @@ jobs: workspaces: src-tauri - run: bun install --frozen-lockfile - - run: bun run build + + - id: updater + uses: ./.github/actions/updater-flags + + # These three mirror the `build` script in package.json. They are spelled out + # here only so the updater `--config` flag can be injected into the middle + # one; keep them in step with that script. + - run: bun run typecheck + - run: bun run tauri build --bundles app ${{ steps.updater.outputs.config }} + - run: bun run dmg - uses: actions/upload-artifact@v4 with: @@ -49,6 +63,18 @@ jobs: path: src-tauri/target/release/bundle/dmg/*.dmg if-no-files-found: error + # The in-app updater installs this tarball, not the DMG: it replaces the + # bundle in place, so it must not be wrapped in a disk image. + - if: steps.updater.outputs.signed == 'true' + uses: actions/upload-artifact@v4 + with: + name: aether-macos-updater + retention-days: 14 + path: | + src-tauri/target/release/bundle/macos/*.app.tar.gz + src-tauri/target/release/bundle/macos/*.app.tar.gz.sig + if-no-files-found: error + linux: name: Linux (x86_64) runs-on: ubuntu-latest @@ -73,7 +99,11 @@ jobs: - run: bun run typecheck:web # Unit tests are platform-independent, so one runner is enough. - run: bun run test - - run: bun run tauri build --bundles deb,appimage + + - id: updater + uses: ./.github/actions/updater-flags + + - run: bun run tauri build --bundles deb,appimage ${{ steps.updater.outputs.config }} - run: scripts/normalize-deb-package.sh src-tauri/target/release/bundle/deb/*.deb - uses: actions/upload-artifact@v4 @@ -85,6 +115,17 @@ jobs: src-tauri/target/release/bundle/appimage/*.AppImage if-no-files-found: error + # AppImage only. A .deb install is owned by the package manager, so ÆTHER + # refuses to self-update there (see updater_install_support in lib.rs) and + # publishing a signature for one would be misleading. + - if: steps.updater.outputs.signed == 'true' + uses: actions/upload-artifact@v4 + with: + name: aether-linux-x86_64-updater + retention-days: 14 + path: src-tauri/target/release/bundle/appimage/*.AppImage.sig + if-no-files-found: error + linux-arm64: name: Linux (ARM64) runs-on: ubuntu-24.04-arm @@ -134,7 +175,11 @@ jobs: - run: bun install --frozen-lockfile - run: bun run typecheck:web - - run: bun run tauri build --bundles nsis + + - id: updater + uses: ./.github/actions/updater-flags + + - run: bun run tauri build --bundles nsis ${{ steps.updater.outputs.config }} - uses: actions/upload-artifact@v4 with: @@ -145,6 +190,14 @@ jobs: src-tauri/target/release/bundle/msi/*.msi if-no-files-found: error + - if: steps.updater.outputs.signed == 'true' + uses: actions/upload-artifact@v4 + with: + name: aether-windows-x86_64-updater + retention-days: 14 + path: src-tauri/target/release/bundle/nsis/*.exe.sig + if-no-files-found: error + android: name: Android (APK) if: github.event_name == 'workflow_dispatch' @@ -198,6 +251,10 @@ jobs: permissions: contents: write steps: + # Needed for scripts/updater-manifest.sh; this job previously only consumed + # artifacts and so did not check out the repository. + - uses: actions/checkout@v4 + - name: Download all build artifacts uses: actions/download-artifact@v4 with: @@ -232,6 +289,24 @@ jobs: copy_first_from_artifact "aether-linux-x86_64" "*.AppImage" "AETHER_amd64.AppImage" copy_first_from_artifact "aether-linux-arm64" "*.deb" "AETHER_arm64.deb" + # The updater downloads this tarball rather than the DMG. Renamed off + # "ÆTHER.app.tar.gz" because a non-ASCII release asset name has to be + # percent-encoded in latest.json, and that is a needless way to fail. + copy_first_from_artifact "aether-macos-updater" "*.app.tar.gz" "AETHER_macOS.app.tar.gz" + + # Writes nothing when no signing key produced signatures, which is the normal + # state before the keypair exists — the release then publishes installers + # only and the app reports the `unconfigured` update status. + - name: Build updater manifest + shell: bash + run: | + scripts/updater-manifest.sh \ + "$GITHUB_REF_NAME" "$GITHUB_REPOSITORY" artifacts release-assets/latest.json + + if [[ ! -f release-assets/latest.json ]]; then + echo "::warning title=No updater manifest::No updater signatures were produced, so latest.json is not published and in-app updates stay disabled for this release." + fi + - name: Create release and attach installers uses: softprops/action-gh-release@v2 with: diff --git a/README.md b/README.md index e62121c..f34a586 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,56 @@ ÆTHER dashboard with portals, knowledge hubs, saved iCE atlases, and AiON

+## Install + +Download the latest build for your platform from +**[Releases](https://github.com/CanPixel/aether/releases/latest)**: + +| Platform | File | +|---|---| +| macOS (Apple Silicon, 11+) | `AETHER_macOS.dmg` | +| Windows (x86_64) | `AETHER_x64-setup.exe` | +| Linux (x86_64) | `AETHER_amd64.deb` · `AETHER_amd64.AppImage` | +| Linux (ARM64) | `AETHER_arm64.deb` | + +> [!NOTE] +> **Intel Macs are not supported.** Releases are built `arm64` only, and Rosetta +> translates Intel binaries to Apple Silicon — not the other way round — so there is +> no way to run this DMG on an Intel Mac. Building `universal-apple-darwin` would fix +> it at the cost of doubling an already slow llama.cpp compile. + +> [!IMPORTANT] +> **Releases are not yet code-signed**, so your OS will block the first launch. This +> is a signing status, not a warning about the app — but you should only work around +> it for software you actually trust, and you can verify what ÆTHER does by reading +> this repository. Signing and notarization are the top roadmap item; the setup guide +> is in [docs/SIGNING.md](docs/SIGNING.md). + +**macOS.** The `.dmg` is unsigned and un-notarized, so macOS quarantines it and +reports *"ÆTHER is damaged and can't be opened"*. It is not damaged. Drag the app to +`/Applications`, then clear the quarantine flag: + +```bash +xattr -dr com.apple.quarantine /Applications/ÆTHER.app +``` + +Then open it normally. (Right-click → *Open* alone does not work for un-notarized +apps on current macOS.) + +**Windows.** SmartScreen shows *"Windows protected your PC"*. Click **More info**, +then **Run anyway**. + +**Linux.** No workaround needed. + +```bash +sudo dpkg -i AETHER_amd64.deb # or: chmod +x AETHER_amd64.AppImage +``` + +After first launch, ÆTHER asks you to install a local model. Only **AiON MiST** +(639 MB) is required — capture, search, and cited passage retrieval all work with it +alone. The chat models are optional and can be added later from the model picker. +See [Local Wisdom Setup](#local-wisdom-setup). + ## The Æther That Is > **New here?** Read the plain-language introduction: [What is ÆTHER?](docs/WHAT-IS-AETHER.md) @@ -807,18 +857,19 @@ iCE depends on the local chat model returning parseable JSON. Try: ## Current Limitations -- macOS packages are local unsigned/ad-hoc builds until Developer ID signing and notarization are configured. +- Tab favicons are fetched directly from each site by the privileged window, so that one request per host is not local. See [docs/SECURITY.md](docs/SECURITY.md). +- Releases are unsigned on macOS and Windows, so the first launch has to be unblocked manually — see [Install](#install) for the steps and [docs/SIGNING.md](docs/SIGNING.md) for the fix. - Capture quality depends on page structure, active webview snapshots, and fallback HTTP extraction quality. - App-like authenticated services can still have browser API or popup edge cases. - iCE generation depends on local model quality and JSON compliance. -- Update checks notify about newer app releases, but they do not download or install updates yet. +- In-app updates are implemented but inert until an updater signing keypair exists; until then the Install button explains that it cannot verify a download. `.deb`/`.rpm` and Linux ARM64 installs never self-update by design. See [docs/SIGNING.md](docs/SIGNING.md#in-app-updates--minisign). - Search and Ask currently use one selected hub plus optional current page, not arbitrary multi-hub selection. ## Roadmap Ideas Likely next improvements: -- Production signing and notarization flow. +- Production signing and notarization flow ([setup guide](docs/SIGNING.md)), and generating the updater keypair that switches in-app updates on. - Import/export for knowledge hubs. - Full capture library view with filtering and bulk actions. - Per-hub retrieval/model settings. diff --git a/docs/LICENSING.md b/docs/LICENSING.md new file mode 100644 index 0000000..99b74f4 --- /dev/null +++ b/docs/LICENSING.md @@ -0,0 +1,123 @@ +# Licensing — options and open questions + +A decision record, not a decision. Nothing here has been applied: ÆTHER is still +under **PolyForm Strict License 1.0.0**. This exists so the reasoning is on paper +when the choice is actually made. + +Not legal advice. Anything with revenue attached to it is worth a lawyer's hour. + +## Three gaps in the current setup + +These are true today and worth fixing **whatever licence is chosen**. + +### 1. Even local modification is not permitted + +PolyForm Strict grants everything *"other than distributing the software **or making +changes or new works based on the software**."* + +That second clause is stricter than it usually reads. It means: + +- A user cannot legally patch a bug for their own machine. +- A contributor cannot legally fork to open a pull request — a fork is + redistribution, and a patch is a new work. + +So the project cannot accept outside contributions without granting permission +out-of-band first, per contributor, before they have written anything. + +### 2. The README promises a CLA that does not exist + +> External contributions require a signed Contributor License Agreement (CLA) or +> another written contributor agreement. + +There is no `CONTRIBUTING.md`, no CLA text, and no CLA bot. The stated route to +contributing terminates in nothing. Either write it or stop referring to it. + +### 3. There is no way to buy a commercial licence + +Redistribution and commercial use "require separate written permission from +CanPixel" — and no email, form, or contact appears anywhere in the repository. The +single revenue path the licence exists to protect has no door in it. + +This one is worth fixing immediately and costs one line. + +## What constrains the choice + +**Nothing in the dependency tree.** Gemma 4 E2B/E4B and Qwen3-Embedding-0.6B are +Apache-2.0; llama.cpp, Tauri, and the rest are Apache-2.0/MIT. No copyleft +obligation reaches ÆTHER's own code. + +**The product shape does.** ÆTHER is a local desktop app with no server. That rules +out the usual open-core playbook — there is no hosted tier to sell, and AGPL has no +leverage because there is no network service to trigger the source obligation. The +realistic revenue is a one-time purchase or a per-seat commercial licence, which is +what PolyForm already reserves. + +**The audience does too.** Researchers, journalists, and people in regulated or +air-gapped environments care about auditability and about not being cut off. Both +argue for source-available with a durable guarantee, and against anything that +makes moving a build onto an offline machine legally awkward. + +## Options + +### A. PolyForm Noncommercial 1.0.0 + +Permits modification and redistribution; still bans commercial use. + +- Fixes all three frictions: local fixes, sneakernet to an air-gapped machine, PRs. +- Gives up nothing sellable — noncommercial use was already permitted. +- Roughly a one-word change in the licence family, plus `package.json` and README. + +The smallest change that removes real friction. Still not OSI open source, so it +does not buy open-source goodwill or a place in distro repositories. + +### B. FSL-1.1-Apache-2.0 (Functional Source License) + +Same commercial protection now; each release converts to Apache-2.0 two years after +it ships. + +- The strongest "this will not be taken away from you" signal short of going open, + which matters for the trust-sensitive audience. +- Irrevocable per release — a promise that cannot be walked back. +- Two years is a long time in this category; by the time a release converts, it is + unlikely to be competitive. + +### C. AGPL-3.0 + commercial dual licence + +Real OSI open source, with a commercial licence sold to anyone who cannot comply. + +- Weak here. The copyleft trigger is *conveying* or *network use*; a local desktop + app with no server rarely trips either, so the commercial pressure that makes + dual-licensing work mostly is not there. +- A competitor could fork commercially provided they publish source. +- Meaningful legal and administrative overhead for a solo project. + +### D. Keep PolyForm Strict, close the gaps + +Write the CLA and `CONTRIBUTING.md` the README already promises, add a commercial +contact. + +- Zero licence risk, and the documentation is owed regardless. +- Contributors still cannot legally fork to submit a PR, so the contribution path + stays theoretical. + +### E. Fully permissive (Apache-2.0 / MIT) + +- Maximum adoption and goodwill. +- No revenue path at all for a local app with no hosted component, and no + protection against a rebranded fork. + +## A correction to the audit that prompted this + +The audit said the current setup has *"the costs of proprietary and the revenue of +open source."* That is unfair as written. PolyForm Strict **does** establish the +legal basis for a commercial story — every commercial right is retained. What is +missing is everything on the other side of it: no price, no tier, no contact. Gap 3 +above is the real finding; the licence family is a secondary question. + +## Suggested order + +1. **Add a commercial-licence contact.** One line. Fixes a gap that exists under + every option, and is the only one currently costing money. +2. **Resolve the CLA claim** — write it, or remove the sentence. +3. **Then** decide between A and B, if either. That decision is about how much + openness is worth to the audience, not about unblocking anything technical. diff --git a/docs/SECURITY.md b/docs/SECURITY.md new file mode 100644 index 0000000..ad61772 --- /dev/null +++ b/docs/SECURITY.md @@ -0,0 +1,93 @@ +# Security Notes + +Not a policy document — a record of the decisions that are easy to undo by accident. + +## Two kinds of webview + +ÆTHER runs visited pages in **child webviews**, separate from the window that hosts +the app's own UI. That split is the main boundary: + +| | Privileged window (`main`) | Child webviews (tabs) | +|---|---|---| +| Content | ÆTHER's own bundled UI | arbitrary web pages | +| IPC bridge | yes — all Tauri commands | no | +| CSP | `app.security.csp` (below) | the site's own | + +A page cannot reach the command bridge, because it is not in the context that has +one. This is why an aggressive CSP on the privileged window costs page +compatibility nothing: the policy never applies to page content. + +## Content Security Policy + +Lives in `src-tauri/tauri.conf.json` under `app.security.csp`, with a looser +`devCsp` for Vite's HMR (inline scripts, `eval`, and a WebSocket to +`127.0.0.1:1420`). Tauri injects it at load. + +**Deliberately not also a `` tag in `index.html`.** It used to be. Two +policies are *intersected* by the engine, so with both in place a tightening in +either silently overrides the other and the pair drifts apart. One source of truth. + +Why each directive is what it is: + +| Directive | Value | Reason | +|---|---|---| +| `default-src` | `'self'` | Nothing loads from anywhere else unless listed below. | +| `script-src` | `'self'` | One bundled module script. No inline, no `eval`. | +| `style-src` | `'self' 'unsafe-inline'` | The UI uses React `style` attributes throughout. This permits inline *style*, not inline script. | +| `img-src` | `'self' data: blob: https: http:` | See favicons below. `data:`/`blob:` are tab thumbnails. | +| `connect-src` | `'self' ipc: http://ipc.localhost` | Tauri's IPC transport. Removing these breaks every command. | +| `object-src` | `'none'` | No plugins, ever. | +| `base-uri` | `'self'` | Stops injected markup repointing relative URLs. | +| `form-action` | `'none'` | The UI has no server to post to. | +| `frame-ancestors` | `'none'` | Nothing may embed the privileged window. | + +**`img-src` allows any host, and that is a real hole.** Tab favicons are fetched +straight from `https:///favicon.ico` by an `` in the privileged window +(`favicon_for_url` in `src-tauri/src/util.rs`). Narrowing this needs favicons +proxied through Rust and cached locally, which would also stop the privileged +window making any outbound request at all. Worth doing; not done. + +## What the app sends anywhere + +Outbound requests, all from Rust except where noted: + +- **Hugging Face** — only while downloading a model the user chose. +- **GitHub Releases API** — the update check, if enabled in Settings. +- **The update endpoint** — only when the user presses Install Update. +- **Pages the user visits** — in child webviews, as any browser. +- **Favicons** — from the privileged window, per the above. + +No analytics, no crash reporting, no phone-home. Captured text, embeddings, +answers, and iCE atlases never leave the machine. + +## Diagnostics log + +`src-tauri/src/diagnostics.rs`. Replaces 25 `eprintln!` calls that went to a stderr +nobody reads — which mattered because the Windows and Linux builds ship without ever +being run, and there is no telemetry to notice a failure. + +- Written only to the app data directory, capped at 512 KiB, rolling over to the + newest half rather than emptying. +- Visible in Settings → Diagnostics (most recent first). +- Leaves the machine only via **Export Log**, which writes a copy and reveals it. + +**Deliberately never recorded:** page text, captured content, search queries, chat +prompts, and answers. Entries are operational — what failed, and where. That is the +constraint that lets the log be exportable at all; an exported log must not be able +to become a transcript of what someone was reading. + +Paths *are* recorded, including model and store paths under the user's home +directory. Worth knowing before attaching a log to a public issue. + +## Capabilities + +`src-tauri/capabilities/default.json` grants `core:default` and `opener:default` to +the `main` window only. The opener permission is what `reveal_item_in_dir` and +external-link opening need. Nothing else is granted, and child webviews appear in no +capability. + +## Known gaps + +- Releases are unsigned; see [SIGNING.md](SIGNING.md). +- `img-src` is open to any host, per above. +- `style-src` needs `'unsafe-inline'` until the UI stops using `style` attributes. diff --git a/docs/SIGNING.md b/docs/SIGNING.md new file mode 100644 index 0000000..77d72c9 --- /dev/null +++ b/docs/SIGNING.md @@ -0,0 +1,195 @@ +# Code Signing and Notarization + +ÆTHER currently ships **unsigned** on every platform. This document is the setup +guide for changing that. + +There are two independent signing schemes here, and they are easy to confuse: + +| | Purpose | Cost | Status | +|---|---|---|---| +| **OS code signing** (Apple Developer ID, Azure Trusted Signing) | Stops the OS blocking the *first install* | ~$220/year | Not wired up — see below | +| **Updater signing** (minisign) | Lets the app verify an *update* it downloads itself | Free | Wired up, waiting on a keypair | + +They are unrelated: the updater's minisign key is generated locally by the Tauri CLI +and has nothing to do with Apple or Microsoft. So [in-app +updates](#in-app-updates--minisign) can be turned on today, without waiting for the +paid certificates. + +Until OS signing is done, see [Install](../README.md#install) for what users have to +do to open an unsigned build. + +## Why it matters + +An unsigned, un-notarized `.dmg` downloaded through a browser is quarantined by +macOS. The user does not get a "are you sure?" prompt they can click through — they +get **"ÆTHER is damaged and can't be opened. You should move it to the Bin."** That +message is indistinguishable from a malware warning, and it is the first thing a new +user sees. + +On Windows, an unsigned NSIS installer triggers SmartScreen's "Windows protected +your PC — unknown publisher" interstitial, where the *Run anyway* button is behind a +*More info* link. + +For a tool whose entire pitch is that it keeps your data on your machine, asking the +user to override their OS's security check is the wrong first impression. + +## macOS — Developer ID + notarization + +**Cost:** Apple Developer Program, $99/year. + +1. **Enrol** at . Individual enrolment is + enough; it can take a day or two to be approved. +2. **Create a Developer ID Application certificate** in Certificates, Identifiers & + Profiles. Note this is *Developer ID Application*, not *Mac App Distribution* — + the latter only works for the Mac App Store and will not help with direct + downloads. +3. **Export it as a `.p12`** from Keychain Access (right-click the certificate → + Export), set a password, then base64 it for CI: + ```bash + base64 -i certificate.p12 | pbcopy + ``` +4. **Create an App Store Connect API key** (Users and Access → Integrations → Keys) + with the *Developer* role. Download the `.p8` — Apple only lets you download it + once. Record the Key ID and Issuer ID shown next to it. +5. **Add GitHub repository secrets:** + + | Secret | Value | + |---|---| + | `APPLE_CERTIFICATE` | base64 of the `.p12` | + | `APPLE_CERTIFICATE_PASSWORD` | the `.p12` export password | + | `APPLE_SIGNING_IDENTITY` | e.g. `Developer ID Application: Your Name (TEAMID)` | + | `APPLE_API_KEY_ID` | Key ID from step 4 | + | `APPLE_API_ISSUER` | Issuer ID from step 4 | + | `APPLE_API_KEY` | base64 of the `.p8` | + +6. **Config changes.** In `src-tauri/tauri.conf.json`, under `bundle.macOS`, add a + hardened-runtime entitlements file. Notarization rejects binaries without the + hardened runtime, and llama.cpp's Metal path needs the JIT entitlement: + + ```xml + + com.apple.security.cs.allow-jit + com.apple.security.cs.allow-unsigned-executable-memory + ``` + + Tauri's bundler reads `APPLE_SIGNING_IDENTITY` and the `APPLE_API_*` variables + directly, so the macOS CI job needs the secrets in `env:` and an import step for + the certificate — it does not need a separate `notarytool` invocation. + +7. **Verify** a built app before trusting the pipeline: + ```bash + codesign --verify --deep --strict --verbose=2 "ÆTHER.app" + spctl --assess --type execute --verbose "ÆTHER.app" + xcrun stapler validate "ÆTHER.app" + ``` + `spctl` must report `accepted source=Notarized Developer ID`. + +**Note on the DMG.** The installer is built by `scripts/make-styled-dmg.sh` via +`appdmg`, not by Tauri's bundler, so the `.dmg` itself needs signing and stapling as +a separate step after it is assembled — signing the `.app` alone is not enough. + +## Windows — Azure Trusted Signing + +**Cost:** roughly $10/month. The older route is an OV or EV certificate from a CA +(DigiCert, Sectigo) at a few hundred dollars a year; EV additionally requires a +hardware token, which does not work in CI without a cloud HSM. Azure Trusted Signing +is the cheaper and more CI-friendly option for a solo project, and it accrues +SmartScreen reputation against Microsoft's root rather than from zero. + +1. Create an Azure account and a **Trusted Signing** account and certificate profile. + Identity validation takes a few days for an individual. +2. Register an app in Entra ID, grant it the *Trusted Signing Certificate Profile + Signer* role, and record the tenant, client ID, and client secret. +3. Add `AZURE_TENANT_ID`, `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET`, + `AZURE_ENDPOINT`, `AZURE_CODE_SIGNING_NAME`, `AZURE_CERT_PROFILE_NAME` as + repository secrets. +4. Set `bundle.windows.signCommand` in `tauri.conf.json` to invoke the Trusted + Signing CLI over `%1`. Tauri passes each artifact path through it. + +Reputation is earned per-publisher over download volume, so expect SmartScreen to +keep warning for the first weeks even once signing works. + +## Linux + +No signing is required for `.deb` or AppImage — neither apt nor the AppImage +runtime enforces publisher identity by default. If ÆTHER is ever published through +Flathub, that has its own review and build process, and signing is handled there. + +## In-app updates — minisign + +This part **is** wired up. Settings → Updates offers *Install Update* when a newer +release exists, downloads it, verifies its signature, and installs it in place. All +that is missing is a keypair, which is free and takes a minute. + +Until the key exists nothing changes: builds are byte-identical to before, and the +Install button reports `unconfigured` — *"This build has no update signing key, so +ÆTHER cannot verify a download"* — rather than fetching something it could not check. + +### Turning it on + +1. **Generate the keypair.** The password may be empty, but a real one is better — + anyone with the private key can push an update to every ÆTHER install. + ```bash + bun run tauri signer generate -w ~/.tauri/aether-updater.key + ``` + This prints a public key and writes the private key to that path. **Back both up + somewhere durable.** Losing the private key means no existing install can ever be + updated again — every user has to reinstall by hand. + +2. **Commit the public key** into `src-tauri/tauri.conf.json` under + `plugins.updater.pubkey`, replacing the empty placeholder. It is a public key; + committing it is the intended usage. + +3. **Add two repository secrets:** + + | Secret | Value | + |---|---| + | `TAURI_SIGNING_PRIVATE_KEY` | contents of `~/.tauri/aether-updater.key` | + | `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | the password from step 1 | + + The moment `TAURI_SIGNING_PRIVATE_KEY` exists, the build jobs start producing + signed updater artifacts and the release job publishes `latest.json`. Nothing else + needs changing — see `.github/actions/updater-flags`. + +4. **Verify** after the next tagged release: + ```bash + curl -sL https://github.com/CanPixel/aether/releases/latest/download/latest.json | jq . + ``` + It should list `darwin-aarch64`, `windows-x86_64`, and `linux-x86_64`, each with a + non-empty `signature` and a URL pinned to the release tag. + +### What updates, and what does not + +| Install | Self-updates | Why | +|---|---|---| +| macOS `.dmg` → `/Applications` | Yes | Replaces the `.app` bundle in place | +| Windows `-setup.exe` | Yes | Re-runs the NSIS installer silently | +| Linux AppImage | Yes | Rewrites the AppImage file | +| Linux `.deb` / `.rpm` | **No** | Owned by the package manager; ÆTHER reports `unsupported` rather than corrupting it | +| Linux ARM64 | **No** | Only a `.deb` is published, so there is no signed artifact | +| Android | **No** | Store-managed | + +### Two things worth knowing + +**`createUpdaterArtifacts` is a hard build error without the private key.** That is +why it lives in `src-tauri/tauri.updater.conf.json` and is merged in only when the +secret exists, instead of sitting in the main config — otherwise every branch push +would fail until the key was created. + +**Updates may sidestep the quarantine problem.** Tauri's updater downloads and +extracts the bundle itself rather than going through a browser, so it should not +attach `com.apple.quarantine` — meaning the `xattr` dance in the README would apply +only to the *first* install, not to subsequent updates. This follows from how +quarantine is applied and has **not been verified against a real signed release**; +confirm it before relying on it. + +## Order of work + +1. **Updater keypair first.** It is free, takes a minute, and is the only item here + that improves anything today — without it, a security fix reaches only users who + happen to re-download by hand. +2. macOS Developer ID second. It is the only platform where the current state is a + hard block rather than a bypassable warning, and it is the platform ÆTHER is + developed on. +3. Windows last. SmartScreen is unpleasant but clickable, and reputation takes + weeks to build regardless of when you start. diff --git a/scripts/updater-manifest.sh b/scripts/updater-manifest.sh new file mode 100755 index 0000000..78390cd --- /dev/null +++ b/scripts/updater-manifest.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Builds the `latest.json` that the in-app updater reads (tauri-plugin-updater's +# "static" manifest format: a version plus one entry per `{os}-{arch}` target). +# +# Signatures are embedded as strings, so the .sig files produced by the bundler are +# read here and never published as release assets. Any platform whose signature is +# missing is left out of the manifest entirely — the app then reports "no build for +# this platform" rather than downloading something it cannot verify. If no platform +# has a signature at all, nothing is written and the script exits 0: that is the +# normal state before an updater signing key exists (see docs/SIGNING.md). +# +# The shape this produces is locked by `updater_manifest_matches_the_plugin_format` +# in src-tauri/src/lib.rs. Change one and the other fails. +# +# Usage: scripts/updater-manifest.sh +set -euo pipefail + +tag="${1:?tag required, e.g. v1.0.30}" +repo="${2:?repo required, e.g. CanPixel/aether}" +artifacts="${3:?artifacts directory required}" +out="${4:?output file required}" + +version="${tag#v}" +base="https://github.com/${repo}/releases/download/${tag}" + +read_sig() { + local source + source="$(find "$artifacts/$1" -type f -name "$2" 2>/dev/null | sort | head -n 1)" + [[ -n "$source" ]] && tr -d '\n' < "$source" +} + +mac_sig="$(read_sig "aether-macos-updater" "*.app.tar.gz.sig" || true)" +win_sig="$(read_sig "aether-windows-x86_64-updater" "*.exe.sig" || true)" +linux_sig="$(read_sig "aether-linux-x86_64-updater" "*.AppImage.sig" || true)" + +if [[ -z "$mac_sig$win_sig$linux_sig" ]]; then + echo "No updater signatures found under $artifacts; not writing $out." >&2 + exit 0 +fi + +# Both macOS arches map to the same tarball: the bundle is built on Apple Silicon, +# and an Intel Mac running it under Rosetta still reports darwin-x86_64 to the +# updater. Drop darwin-x86_64 here if a real Intel or universal build is ever +# published as a separate asset. +jq -n \ + --arg version "$version" \ + --arg pub_date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --arg notes "See https://github.com/${repo}/releases/tag/${tag}" \ + --arg mac_sig "$mac_sig" \ + --arg mac_url "$base/AETHER_macOS.app.tar.gz" \ + --arg win_sig "$win_sig" \ + --arg win_url "$base/AETHER_x64-setup.exe" \ + --arg linux_sig "$linux_sig" \ + --arg linux_url "$base/AETHER_amd64.AppImage" \ + '{ + version: $version, + notes: $notes, + pub_date: $pub_date, + platforms: ( + {} + + (if $mac_sig == "" then {} else { + "darwin-aarch64": { signature: $mac_sig, url: $mac_url }, + "darwin-x86_64": { signature: $mac_sig, url: $mac_url } + } end) + + (if $win_sig == "" then {} else { + "windows-x86_64": { signature: $win_sig, url: $win_url } + } end) + + (if $linux_sig == "" then {} else { + "linux-x86_64": { signature: $linux_sig, url: $linux_url } + } end) + ) + }' > "$out" + +echo "Wrote $out for $version with targets:" >&2 +jq -c '.platforms | keys' "$out" >&2 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 6553c1e..a5e27db 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -22,6 +22,7 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-opener", + "tauri-plugin-updater", "tokio", "url", "uuid", @@ -66,6 +67,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -762,6 +772,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -1067,6 +1088,16 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1979,6 +2010,36 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + [[package]] name = "jni-sys" version = "0.3.1" @@ -2263,6 +2324,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "minisign-verify" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2505,6 +2572,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.0", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -2520,6 +2588,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.13.0", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -2595,6 +2675,12 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" @@ -2611,6 +2697,20 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "pango" version = "0.18.3" @@ -3216,15 +3316,20 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3292,6 +3397,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -3302,6 +3419,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.103.13" @@ -3334,6 +3478,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -3406,6 +3559,29 @@ dependencies = [ "tendril 0.4.3", ] +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.31.0" @@ -3663,6 +3839,22 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.3" @@ -3889,7 +4081,7 @@ dependencies = [ "gdkwayland-sys", "gdkx11-sys", "gtk", - "jni", + "jni 0.21.1", "libc", "log", "ndk", @@ -3922,6 +4114,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -3945,7 +4148,7 @@ dependencies = [ "gtk", "heck 0.5.0", "http", - "jni", + "jni 0.21.1", "libc", "log", "mime", @@ -4079,6 +4282,39 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-updater" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "806d9dac662c2e4594ff03c647a552f2c9bd544e7d0f683ec58f872f952ce4af" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest 0.13.4", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.18", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip", +] + [[package]] name = "tauri-runtime" version = "2.11.2" @@ -4089,7 +4325,7 @@ dependencies = [ "dpi", "gtk", "http", - "jni", + "jni 0.21.1", "objc2", "objc2-ui-kit", "objc2-web-kit", @@ -4112,7 +4348,7 @@ checksum = "b83849ee63ecb27a8e8d0fe51915ca215076914aca43f96db1179f0f415f6cd9" dependencies = [ "gtk", "http", - "jni", + "jni 0.21.1", "log", "objc2", "objc2-app-kit", @@ -4997,6 +5233,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "1.0.7" @@ -5628,7 +5873,7 @@ dependencies = [ "gtk", "http", "javascriptcore-rs", - "jni", + "jni 0.21.1", "libc", "ndk", "objc2", @@ -5675,6 +5920,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.3" @@ -5839,6 +6094,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.14.0", + "memchr", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 4f57194..9e58109 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -35,6 +35,12 @@ uuid = { version = "1", features = ["v4", "serde"] } # features are not unified into non-test builds, so this does not affect the app. tokio = { version = "1", features = ["fs", "io-util", "macros", "rt"] } +# The updater replaces the installed bundle in place, which only means anything on +# desktop: Android and iOS updates go through their stores. Keeping the plugin out +# of the mobile dependency graph entirely also keeps it from breaking those builds. +[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] +tauri-plugin-updater = "2" + [target.'cfg(target_os = "macos")'.dependencies] # Cargo unions features across target tables, so on macOS llama.cpp gets metal + sampler. llama-cpp-2 = { version = "0.1.146", default-features = false, features = ["metal"] } diff --git a/src-tauri/src/air.rs b/src-tauri/src/air.rs new file mode 100644 index 0000000..994f74d --- /dev/null +++ b/src-tauri/src/air.rs @@ -0,0 +1,791 @@ +//! AiR: rendering a set of captures into a Markdown dossier on disk. + +use super::*; + +pub(crate) async fn air_prepare_dossier( + state: &State<'_, Backend>, + input: AirDossierInput, + synthesize: bool, +) -> Cmd { + let lens_kind = input.lens_kind.unwrap_or_default(); + let lens = input.lens.trim().to_string(); + let visible_lens = if lens.is_empty() { + match lens_kind { + AirLensKind::Topic => "Local knowledge".to_string(), + AirLensKind::Flow => "Current Flow lens".to_string(), + AirLensKind::Hub => "Selected knowledge hub".to_string(), + AirLensKind::Answer => "Latest AiON answer".to_string(), + AirLensKind::Iceberg => "Saved iCE map".to_string(), + } + } else { + lens + }; + let generated_at = now(); + let limit = input.limit.unwrap_or(12).clamp(1, 24); + let (sources, seed_answer, ice_map, seed_model) = + air_gather_context(state, &input, lens_kind, &visible_lens, limit).await?; + let title = format!("AiR Dossier: {visible_lens}"); + let output_dir = resolve_air_export_dir(&state.paths, false).await?; + + let mut model = seed_model.unwrap_or_else(|| "deterministic-scaffold".to_string()); + let synthesized_sections = if synthesize { + match air_synthesize_sections( + state, + &visible_lens, + lens_kind, + &sources, + seed_answer.as_deref(), + ice_map.as_deref(), + ) + .await + { + Ok((synthesis_model, sections)) => { + model = synthesis_model; + Some(sections) + } + Err(_) => None, + } + } else { + None + }; + + let markdown_preview = build_air_markdown(AirMarkdownInput { + title: &title, + lens: &visible_lens, + lens_kind, + generated_at: &generated_at, + model: &model, + sources: &sources, + synthesized_sections: synthesized_sections.as_deref(), + seed_answer: seed_answer.as_deref(), + ice_map: ice_map.as_deref(), + }); + + Ok(AirPreparedDossier { + title, + lens: visible_lens, + lens_kind, + generated_at, + model: Some(model), + output_dir: output_dir.display().to_string(), + markdown_preview, + sources, + }) +} + +pub(crate) async fn air_gather_context( + state: &State<'_, Backend>, + input: &AirDossierInput, + lens_kind: AirLensKind, + lens: &str, + limit: usize, +) -> Cmd<( + Vec, + Option, + Option, + Option, +)> { + match lens_kind { + AirLensKind::Answer => { + let Some(answer) = input.answer.clone() else { + return Ok((Vec::new(), None, None, None)); + }; + let library = load_library(&state.paths.library_path).await?; + let collection_names = library + .collections + .iter() + .map(|collection| (collection.id.clone(), collection.name.clone())) + .collect::>(); + let sources = search_results_to_air_sources( + dedupe_citations(answer.citations) + .into_iter() + .take(limit) + .collect(), + &collection_names, + ); + Ok((sources, Some(answer.answer), None, Some(answer.model))) + } + AirLensKind::Iceberg => { + let icebergs = load_icebergs(&state.paths.icebergs_path).await?; + let selected = input + .saved_iceberg_id + .as_deref() + .and_then(|id| icebergs.icebergs.iter().find(|iceberg| iceberg.id == id)) + .or_else(|| { + icebergs + .icebergs + .iter() + .find(|iceberg| iceberg.title.eq_ignore_ascii_case(lens)) + }) + .or_else(|| icebergs.icebergs.first()); + let Some(iceberg) = selected else { + return Ok((Vec::new(), None, None, None)); + }; + let mut items = iceberg.iceberg.items.clone(); + items.sort_by(|left, right| { + left.level + .cmp(&right.level) + .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase())) + }); + let sources = items + .iter() + .take(limit) + .map(|item| AirDossierSource { + id: format!("iceberg-{}", item.id), + title: item.name.clone(), + excerpt: format!("Level {}: {}", item.level, item.description), + collection_name: Some(format!("iCE · {}", iceberg.title)), + url: None, + host: None, + captured_at: Some(iceberg.updated_at.clone()), + score: None, + }) + .collect::>(); + let map = items + .iter() + .map(|item| { + format!( + "- Level {} · {}: {}", + item.level, item.name, item.description + ) + }) + .collect::>() + .join("\n"); + Ok(( + sources, + None, + Some(map), + Some(iceberg.iceberg.model.clone()), + )) + } + AirLensKind::Hub => { + let sources = air_sources_for_hub(state, input.collection_id.as_deref(), limit).await?; + Ok((sources, None, None, None)) + } + AirLensKind::Flow => { + if let Some(collection_id) = input.collection_id.as_deref() { + let sources = air_sources_for_hub(state, Some(collection_id), limit).await?; + return Ok((sources, None, None, None)); + } + let mut sources = match input.capture_id.as_deref() { + Some(capture_id) => air_sources_for_capture(state, capture_id).await?, + None => Vec::new(), + }; + let existing = sources + .iter() + .map(|source| source.id.clone()) + .collect::>(); + let related = air_sources_for_topic(state, lens, limit).await?; + sources.extend( + related + .into_iter() + .filter(|source| !existing.contains(&source.id)) + .take(limit.saturating_sub(sources.len())), + ); + Ok((sources, None, None, None)) + } + AirLensKind::Topic => { + let sources = air_sources_for_topic(state, lens, limit).await?; + Ok((sources, None, None, None)) + } + } +} + +pub(crate) async fn air_sources_for_hub( + state: &State<'_, Backend>, + collection_id: Option<&str>, + limit: usize, +) -> Cmd> { + let library = load_library(&state.paths.library_path).await?; + let collection_names = library + .collections + .iter() + .map(|collection| (collection.id.clone(), collection.name.clone())) + .collect::>(); + let vectors = with_vectors_read(state, |vectors| vectors.chunks.clone()).await?; + let mut chunks_by_capture = HashMap::>::new(); + for chunk in vectors { + chunks_by_capture + .entry(chunk.capture_id.clone()) + .or_default() + .push(chunk); + } + let mut captures = library + .captures + .into_iter() + .filter(|capture| { + collection_id + .map(|id| capture.collection_id == id) + .unwrap_or(true) + }) + .collect::>(); + captures.sort_by(|left, right| right.captured_at.cmp(&left.captured_at)); + Ok(captures + .into_iter() + .take(limit) + .map(|capture| { + let excerpt = chunks_by_capture + .get(&capture.id) + .and_then(|chunks| chunks.iter().max_by_key(|chunk| chunk.text.chars().count())) + .map(|chunk| semantic_trail_excerpt(&chunk.text, 700)) + .or_else(|| { + capture + .metadata + .as_ref() + .and_then(|metadata| metadata.summary.clone()) + }) + .unwrap_or_default(); + AirDossierSource { + id: capture.id.clone(), + title: capture.title, + excerpt, + collection_name: collection_names.get(&capture.collection_id).cloned(), + url: Some(capture.url.clone()), + host: Some(get_tab_host(&capture.url)), + captured_at: Some(capture.captured_at), + score: None, + } + }) + .collect()) +} + +pub(crate) async fn air_sources_for_capture( + state: &State<'_, Backend>, + capture_id: &str, +) -> Cmd> { + let library = load_library(&state.paths.library_path).await?; + let collection_names = library + .collections + .iter() + .map(|collection| (collection.id.clone(), collection.name.clone())) + .collect::>(); + let Some(capture) = library + .captures + .iter() + .find(|capture| capture.id == capture_id) + .cloned() + else { + return Ok(Vec::new()); + }; + let mut chunks = with_vectors_read(state, |vectors| { + vectors + .chunks + .iter() + .filter(|chunk| chunk.capture_id == capture_id) + .cloned() + .collect::>() + }) + .await?; + chunks.sort_by_key(|chunk| chunk.chunk_index); + let excerpt = semantic_trail_excerpt( + &chunks + .iter() + .map(|chunk| chunk.text.as_str()) + .collect::>() + .join("\n\n"), + 1200, + ); + Ok(vec![AirDossierSource { + id: capture.id.clone(), + title: capture.title, + excerpt, + collection_name: collection_names.get(&capture.collection_id).cloned(), + url: Some(capture.url.clone()), + host: Some(get_tab_host(&capture.url)), + captured_at: Some(capture.captured_at), + score: Some(100.0), + }]) +} + +pub(crate) async fn air_sources_for_topic( + state: &State<'_, Backend>, + lens: &str, + limit: usize, +) -> Cmd> { + let library = load_library(&state.paths.library_path).await?; + let collection_names = library + .collections + .iter() + .map(|collection| (collection.id.clone(), collection.name.clone())) + .collect::>(); + let vectors = with_vectors_read(state, |vectors| vectors.chunks.clone()).await?; + if vectors.is_empty() { + return Ok(Vec::new()); + } + + let results = if lens.trim().is_empty() || lens == "Current Flow lens" { + let mut chunks = vectors; + chunks.sort_by(|left, right| right.captured_at.cmp(&left.captured_at)); + chunks + .into_iter() + .take(limit * 2) + .map(|chunk| SearchResult { + score: 0.0, + id: chunk.id, + collection_id: chunk.collection_id, + capture_id: chunk.capture_id, + app_id: chunk.app_id, + title: chunk.title, + url: chunk.url, + captured_at: chunk.captured_at, + chunk_index: chunk.chunk_index, + text: chunk.text, + }) + .collect::>() + } else { + let settings = load_settings(&state.paths.settings_path).await?; + let query_vector = local_embed_query(state, &settings, lens.to_string()).await?; + let mut scored = vectors + .into_iter() + .filter_map(|chunk| { + let distance = cosine_distance(&query_vector, &chunk.vector); + if !distance.is_finite() { + return None; + } + Some((distance, chunk)) + }) + .collect::>(); + scored.sort_by(|left, right| left.0.partial_cmp(&right.0).unwrap_or(Ordering::Equal)); + scored + .into_iter() + .take(limit * 3) + .map(|(score, chunk)| SearchResult { + score, + id: chunk.id, + collection_id: chunk.collection_id, + capture_id: chunk.capture_id, + app_id: chunk.app_id, + title: chunk.title, + url: chunk.url, + captured_at: chunk.captured_at, + chunk_index: chunk.chunk_index, + text: chunk.text, + }) + .collect::>() + }; + Ok(search_results_to_air_sources( + dedupe_citations(results).into_iter().take(limit).collect(), + &collection_names, + )) +} + +pub(crate) fn search_results_to_air_sources( + results: Vec, + collection_names: &HashMap, +) -> Vec { + results + .into_iter() + .map(|result| { + let collection_name = collection_names.get(&result.collection_id).cloned(); + AirDossierSource { + id: result.capture_id.clone(), + title: result.title, + excerpt: semantic_trail_excerpt(&result.text, 850), + collection_name, + url: Some(result.url.clone()), + host: Some(get_tab_host(&result.url)), + captured_at: Some(result.captured_at), + score: Some(round_score(semantic_score_from_distance(result.score))), + } + }) + .collect() +} + +pub(crate) async fn air_synthesize_sections( + state: &State<'_, Backend>, + lens: &str, + lens_kind: AirLensKind, + sources: &[AirDossierSource], + seed_answer: Option<&str>, + ice_map: Option<&str>, +) -> Cmd<(String, String)> { + let settings = load_settings(&state.paths.settings_path).await?; + let citations = sources + .iter() + .enumerate() + .map(|(index, source)| SearchResult { + id: source.id.clone(), + collection_id: source.collection_name.clone().unwrap_or_default(), + capture_id: source.id.clone(), + app_id: "air".to_string(), + title: source.title.clone(), + url: source + .url + .clone() + .unwrap_or_else(|| format!("aether://air/source/{}", index + 1)), + captured_at: source.captured_at.clone().unwrap_or_else(now), + chunk_index: index, + text: source.excerpt.clone(), + score: source.score.unwrap_or(0.0), + }) + .collect::>(); + let source_rule = if citations.is_empty() { + "No local source excerpts matched. Say that coverage is currently sparse." + } else { + "Use only the numbered local source excerpts. Cite every concrete claim with bracket citations like [1] or [2]." + }; + let prompt = format!( + "Render a concise Markdown dossier for Æther AiR.\nLens kind: {}\nLens: {lens}\n{source_rule}\n\nInclude exactly these sections:\n## Summary\n## Key Findings\n## Source-Backed Notes\n## Unresolved Questions\n\nDo not include YAML frontmatter, a title, or a source index. Keep prose dense and professional.\n\nSeed AiON answer:\n{}\n\nSaved iCE map:\n{}", + air_lens_label(lens_kind), + seed_answer.unwrap_or("None"), + ice_map.unwrap_or("None") + ); + let result = local_chat(state, &settings, &prompt, citations, &[], None).await?; + Ok(( + result.model, + normalize_model_markdown_citations(&result.answer, sources.len()), + )) +} + +pub(crate) struct AirMarkdownInput<'a> { + pub(crate) title: &'a str, + pub(crate) lens: &'a str, + pub(crate) lens_kind: AirLensKind, + pub(crate) generated_at: &'a str, + pub(crate) model: &'a str, + pub(crate) sources: &'a [AirDossierSource], + pub(crate) synthesized_sections: Option<&'a str>, + pub(crate) seed_answer: Option<&'a str>, + pub(crate) ice_map: Option<&'a str>, +} + +pub(crate) fn build_air_markdown(input: AirMarkdownInput<'_>) -> String { + let tags = air_tags(input.lens) + .into_iter() + .map(|tag| yaml_string(&tag)) + .collect::>() + .join(", "); + let mut markdown = format!( + "---\ntitle: {}\ncreated: {}\naether_lens: {}\nsource_count: {}\ntags: [{}]\nmodel: {}\ntype: aether-dossier\n---\n\n# {}\n\n_Rendered by AiR from the {} lens on {}._\n\n", + yaml_string(input.title), + yaml_string(input.generated_at), + yaml_string(input.lens), + input.sources.len(), + tags, + yaml_string(input.model), + markdown_escape(input.title), + air_lens_label(input.lens_kind), + markdown_escape(input.generated_at) + ); + + if let Some(sections) = input + .synthesized_sections + .map(str::trim) + .filter(|sections| !sections.is_empty()) + { + markdown.push_str(sections); + markdown.push_str("\n\n"); + } else { + markdown.push_str("## Summary\n\n"); + if input.sources.is_empty() { + markdown.push_str(&format!( + "No local sources matched [[{}]] yet. This dossier preserves the requested lens and can be regenerated after more captures are available.\n\n", + markdown_escape(input.lens) + )); + } else { + markdown.push_str(&format!( + "This dossier gathers {} local source{} around [[{}]]. It is generated as a deterministic citation scaffold from Æther's captured knowledge.\n\n", + input.sources.len(), + if input.sources.len() == 1 { "" } else { "s" }, + markdown_escape(input.lens) + )); + } + + markdown.push_str("## Key Findings\n\n"); + if input.sources.is_empty() { + markdown.push_str("- Coverage is sparse; capture or connect more relevant material before treating this lens as complete.\n\n"); + } else { + for (index, source) in input.sources.iter().take(6).enumerate() { + markdown.push_str(&format!( + "- **{}** anchors this lens with: {} [^{}]\n", + markdown_escape(&source.title), + markdown_escape(&semantic_trail_excerpt(&source.excerpt, 180)), + index + 1 + )); + } + markdown.push('\n'); + } + + markdown.push_str("## Source-Backed Notes\n\n"); + for (index, source) in input.sources.iter().enumerate() { + markdown.push_str(&format!( + "### {}. {}\n\n{}\n\n", + index + 1, + markdown_escape(&source.title), + markdown_escape(&source.excerpt) + )); + let mut meta = Vec::new(); + if let Some(collection) = &source.collection_name { + meta.push(format!("Hub: {}", markdown_escape(collection))); + } + if let Some(host) = &source.host { + meta.push(format!("Host: {}", markdown_escape(host))); + } + if let Some(captured_at) = &source.captured_at { + meta.push(format!("Captured: {}", markdown_escape(captured_at))); + } + if let Some(score) = source.score { + meta.push(format!("Match: {score:.1}")); + } + if !meta.is_empty() { + markdown.push_str(&format!("{}\n\n", meta.join(" · "))); + } + } + + if let Some(answer) = input + .seed_answer + .map(str::trim) + .filter(|answer| !answer.is_empty()) + { + markdown.push_str("## AiON Seed\n\n"); + markdown.push_str(&markdown_escape(answer)); + markdown.push_str("\n\n"); + } + + if let Some(map) = input.ice_map.map(str::trim).filter(|map| !map.is_empty()) { + markdown.push_str("## iCE Map\n\n"); + markdown.push_str(&markdown_escape(map)); + markdown.push_str("\n\n"); + } + + markdown.push_str("## Unresolved Questions\n\n"); + markdown.push_str("- Which claims need fresher or primary-source confirmation?\n"); + markdown.push_str("- Which adjacent hubs should be captured next to improve coverage?\n\n"); + } + + markdown.push_str("## Source Index\n\n"); + if input.sources.is_empty() { + markdown.push_str("No source index entries were available for this render.\n"); + } else { + for (index, source) in input.sources.iter().enumerate() { + let title = markdown_escape(&source.title); + let collection = source + .collection_name + .as_deref() + .map(markdown_escape) + .unwrap_or_else(|| "Knowledge Hub".to_string()); + let captured_at = source + .captured_at + .as_deref() + .map(markdown_escape) + .unwrap_or_else(|| "unknown capture date".to_string()); + if let Some(url) = &source.url { + markdown.push_str(&format!( + "[^{}]: [{}]({}) — {} · {}\n", + index + 1, + title, + url.replace(')', "%29"), + collection, + captured_at + )); + } else { + markdown.push_str(&format!( + "[^{}]: {} — {} · {}\n", + index + 1, + title, + collection, + captured_at + )); + } + } + } + markdown +} + +pub(crate) async fn resolve_air_export_dir(paths: &DataPaths, create: bool) -> Cmd { + let preferred = documents_air_dir(); + let candidates = preferred + .into_iter() + .chain(std::iter::once(paths.air_exports_path.clone())) + .collect::>(); + for candidate in candidates { + if !create { + if candidate.exists() + || candidate + .parent() + .is_some_and(|parent| parent.exists() && parent.is_dir()) + { + return Ok(candidate); + } + continue; + } + if tokio::fs::create_dir_all(&candidate).await.is_ok() { + return Ok(candidate); + } + } + if create { + Err("Could not create an AiR export folder.".to_string()) + } else { + Ok(paths.air_exports_path.clone()) + } +} + +pub(crate) fn documents_air_dir() -> Option { + env::var_os("HOME") + .or_else(|| env::var_os("USERPROFILE")) + .map(PathBuf::from) + .map(|home| home.join("Documents").join("Æther").join("AiR")) +} + +pub(crate) async fn air_list_recent(paths: &DataPaths) -> Cmd> { + let mut files = Vec::::new(); + let mut directories = Vec::new(); + if let Some(documents) = documents_air_dir() { + directories.push(documents); + } + directories.push(paths.air_exports_path.clone()); + + for directory in directories { + let Ok(mut entries) = tokio::fs::read_dir(&directory).await else { + continue; + }; + while let Some(entry) = entries + .next_entry() + .await + .map_err(|error| error.to_string())? + { + let path = entry.path(); + if !path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("md")) + { + continue; + } + let filename = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("aether-dossier.md") + .to_string(); + let metadata = entry.metadata().await.ok(); + let rendered_at = metadata + .and_then(|metadata| metadata.modified().ok()) + .map(|modified| { + DateTime::::from(modified) + .to_rfc3339_opts(chrono::SecondsFormat::Millis, true) + }) + .unwrap_or_else(now); + let content = tokio::fs::read_to_string(&path).await.unwrap_or_default(); + files.push(AirRecentFile { + title: air_frontmatter_value(&content, "title") + .unwrap_or_else(|| air_title_from_filename(&filename)), + lens: air_frontmatter_value(&content, "aether_lens").unwrap_or_default(), + source_count: air_frontmatter_value(&content, "source_count") + .and_then(|value| value.parse::().ok()) + .unwrap_or(0), + path: path.display().to_string(), + filename, + rendered_at, + }); + } + } + files.sort_by(|left, right| right.rendered_at.cmp(&left.rendered_at)); + files.truncate(12); + Ok(files) +} + +pub(crate) fn air_frontmatter_value(markdown: &str, key: &str) -> Option { + let mut lines = markdown.lines(); + if lines.next()? != "---" { + return None; + } + let prefix = format!("{key}:"); + for line in lines { + if line == "---" { + break; + } + if let Some(value) = line.strip_prefix(&prefix) { + return Some(value.trim().trim_matches('"').replace("\\\"", "\"")); + } + } + None +} + +pub(crate) fn air_dossier_filename(title: &str, generated_at: &str) -> String { + let _ = generated_at; + let filename = title + .trim() + .chars() + .map(|char| match char { + '/' | '\\' | '\0' => '-', + char if char.is_control() => ' ', + char => char, + }) + .collect::() + .split_whitespace() + .collect::>() + .join(" ") + .trim_matches(['.', ' ']) + .to_string(); + if filename.is_empty() { + "AiR Dossier.md".to_string() + } else { + format!("{filename}.md") + } +} + +pub(crate) fn air_title_from_filename(filename: &str) -> String { + filename + .trim_end_matches(".md") + .split('-') + .filter(|part| !part.chars().all(|char| char.is_ascii_digit())) + .take(8) + .map(|part| { + let mut chars = part.chars(); + match chars.next() { + Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()), + None => String::new(), + } + }) + .collect::>() + .join(" ") +} + +pub(crate) fn yaml_string(value: &str) -> String { + format!( + "\"{}\"", + value + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace(['\n', '\r'], " ") + ) +} + +pub(crate) fn markdown_escape(value: &str) -> String { + strip_numeric_bracket_markers(value) + .replace('\r', "") + .trim() + .to_string() +} + +pub(crate) fn air_tags(lens: &str) -> Vec { + let mut tags = vec![ + "aether".to_string(), + "air".to_string(), + "dossier".to_string(), + ]; + for word in lens + .split(|char: char| !char.is_alphanumeric()) + .filter(|word| word.chars().count() >= 3) + .take(4) + { + tags.push(word.to_lowercase()); + } + tags.sort(); + tags.dedup(); + tags +} + +pub(crate) fn air_lens_label(kind: AirLensKind) -> &'static str { + match kind { + AirLensKind::Topic => "topic", + AirLensKind::Flow => "Flow", + AirLensKind::Hub => "hub", + AirLensKind::Answer => "AiON answer", + AirLensKind::Iceberg => "iCE", + } +} + +pub(crate) fn normalize_model_markdown_citations(markdown: &str, source_count: usize) -> String { + normalize_answer_citations(&clean_model_output(markdown), source_count) +} diff --git a/src-tauri/src/chat.rs b/src-tauri/src/chat.rs new file mode 100644 index 0000000..6642233 --- /dev/null +++ b/src-tauri/src/chat.rs @@ -0,0 +1,319 @@ +//! Chat prompt assembly and model-output cleanup. +//! +//! The cleanup half exists because a local model's raw output is not presentable: +//! it leaks stop markers, invents citation numbers past the source count, and +//! spaces brackets inconsistently. + +use super::*; + +pub(crate) fn chat_citation_limit() -> usize { + if cfg!(mobile) { + 5 + } else { + 8 + } +} + +pub(crate) fn chat_snippet_char_limit() -> usize { + if cfg!(mobile) { + 1100 + } else { + usize::MAX + } +} + +pub(crate) fn build_chat_messages( + prompt: &str, + citations: &[SearchResult], + history: &[ConversationTurn], +) -> Vec { + let snippet_limit = chat_snippet_char_limit(); + let context_block = citations + .iter() + .enumerate() + .map(|(index, item)| { + let mut source_text = strip_numeric_bracket_markers(&item.text); + if source_text.chars().count() > snippet_limit { + source_text = source_text.chars().take(snippet_limit).collect(); + } + format!( + "[{}] {}\nURL: {}\nCollection: {}\n{}", + index + 1, + item.title, + item.url, + item.collection_id, + source_text + ) + }) + .collect::>() + .join("\n\n"); + let context = if context_block.is_empty() { + "No stored context was retrieved." + } else { + &context_block + }; + // Phones decode on CPU, so every generated token is user-visible latency: + // steer the model toward tight answers instead of hard-truncating them. + let brevity = if cfg!(mobile) { + " Keep answers concise: a few sentences or a short list unless the question needs more." + } else { + "" + }; + let mut messages = vec![ChatPromptMessage { + role: "system", + content: format!( + "You are Æther, a private local research assistant. Answer only from the supplied local collection context. If the context is insufficient, say what is missing. Cite sources only with Æther source numbers [1] through [{}]. Do not copy bracketed reference numbers from webpage text.{brevity}", + citations.len().max(1) + ), + }]; + + // Prior turns come first so "what about that?" resolves, but each answer is + // condensed: the citations for *this* question must stay the dominant context. + for turn in history { + messages.push(ChatPromptMessage { + role: "user", + content: turn.prompt.clone(), + }); + messages.push(ChatPromptMessage { + role: "assistant", + content: condense_history_answer(&turn.answer), + }); + } + + messages.push(ChatPromptMessage { + role: "user", + content: format!("Local collection context:\n{context}\n\nQuestion: {prompt}"), + }); + messages +} + +// Strips citation markers and clips length. Replaying markers from an old answer +// would let the model reuse source numbers that no longer refer to anything. +pub(crate) fn condense_history_answer(answer: &str) -> String { + let cleaned = strip_numeric_bracket_markers(answer); + let trimmed = cleaned.trim(); + if trimmed.chars().count() <= PROMPT_HISTORY_ANSWER_CHARS { + return trimmed.to_string(); + } + let mut condensed = trimmed + .chars() + .take(PROMPT_HISTORY_ANSWER_CHARS) + .collect::(); + condensed.push('…'); + condensed +} + +pub(crate) fn render_model_chat_prompt( + model: &LlamaModel, + messages: &[ChatPromptMessage], +) -> Cmd { + let template = match model.chat_template(None) { + Ok(template) => template, + Err(_) => { + return Ok(RenderedChatPrompt { + prompt: fallback_chat_prompt(messages), + add_bos: AddBos::Never, + }) + } + }; + let chat = messages + .iter() + .map(|message| LlamaChatMessage::new(message.role.to_string(), message.content.clone())) + .collect::, _>>() + .map_err(|error| error.to_string())?; + match model.apply_chat_template(&template, &chat, true) { + Ok(prompt) => Ok(RenderedChatPrompt { + prompt, + add_bos: AddBos::Never, + }), + Err(_) => Ok(RenderedChatPrompt { + prompt: fallback_chat_prompt(messages), + add_bos: AddBos::Never, + }), + } +} + +pub(crate) fn fallback_chat_prompt(messages: &[ChatPromptMessage]) -> String { + let mut prompt = String::from(""); + let mut system_messages = Vec::new(); + + for message in messages { + match message.role { + "system" | "developer" => { + system_messages.push(message.content.trim().to_string()); + } + "assistant" => { + prompt.push_str("<|turn>model\n"); + prompt.push_str(message.content.trim()); + prompt.push_str("\n"); + } + "user" => { + if !system_messages.is_empty() { + prompt.push_str("<|turn>system\n"); + prompt.push_str(&system_messages.join("\n\n")); + prompt.push_str("\n"); + system_messages.clear(); + } + prompt.push_str("<|turn>user\n"); + prompt.push_str(message.content.trim()); + prompt.push_str("\n"); + } + role => { + prompt.push_str("<|turn>"); + prompt.push_str(role); + prompt.push('\n'); + prompt.push_str(message.content.trim()); + prompt.push_str("\n"); + } + } + } + + if !system_messages.is_empty() { + prompt.push_str("<|turn>system\n"); + prompt.push_str(&system_messages.join("\n\n")); + prompt.push_str("\n"); + } + + prompt.push_str("<|turn>model\n"); + prompt +} + +// Streaming deltas hold back a short tail starting at the most recent '<' so a +// stop marker arriving across several tokens is never shown to the user. +pub(crate) fn stream_safe_len(output: &str) -> usize { + let tail_start = output.len().saturating_sub(18); + let Some((boundary, _)) = output + .char_indices() + .find(|(index, _)| *index >= tail_start) + else { + return output.len(); + }; + match output[boundary..].rfind('<') { + Some(position) => boundary + position, + None => output.len(), + } +} + +pub(crate) fn contains_stop_marker(output: &str) -> bool { + output.contains("") + || output.contains("") + || output.contains("") + || output.contains("<|turn>") + || output.contains("<|eot_id|>") + || output.contains("<|end|>") +} + +pub(crate) fn clean_model_output(output: &str) -> String { + let mut cleaned = output.to_string(); + for marker in [ + "", + "model", + "assistant", + "", + "", + "<|turn>model", + "<|turn>assistant", + "<|turn>user", + "<|turn>system", + "<|turn>", + "<|eot_id|>", + "<|end|>", + ] { + cleaned = cleaned.replace(marker, ""); + } + cleaned.trim().to_string() +} + +pub(crate) fn normalize_answer_citations(answer: &str, citation_count: usize) -> String { + tidy_citation_spacing(&rewrite_numeric_bracket_markers( + answer, + citation_count, + true, + )) +} + +pub(crate) fn strip_numeric_bracket_markers(text: &str) -> String { + rewrite_numeric_bracket_markers(text, 0, false) +} + +pub(crate) fn rewrite_numeric_bracket_markers( + text: &str, + citation_count: usize, + keep_valid: bool, +) -> String { + let mut rewritten = String::with_capacity(text.len()); + let mut cursor = 0usize; + + while let Some(relative_start) = text[cursor..].find('[') { + let start = cursor + relative_start; + let Some(relative_end) = text[start + 1..].find(']') else { + break; + }; + let end = start + 1 + relative_end; + let inner = &text[start + 1..end]; + let Some(numbers) = parse_numeric_citation_marker(inner) else { + rewritten.push_str(&text[cursor..=start]); + cursor = start + 1; + continue; + }; + + rewritten.push_str(&text[cursor..start]); + if keep_valid { + let valid = numbers + .into_iter() + .filter(|number| *number > 0 && *number <= citation_count) + .map(|number| number.to_string()) + .collect::>(); + if !valid.is_empty() { + rewritten.push('['); + rewritten.push_str(&valid.join(", ")); + rewritten.push(']'); + } + } + cursor = end + 1; + } + + rewritten.push_str(&text[cursor..]); + rewritten +} + +pub(crate) fn parse_numeric_citation_marker(value: &str) -> Option> { + if value.trim().is_empty() + || !value.chars().all(|character| { + character.is_ascii_digit() || character == ',' || character.is_whitespace() + }) + { + return None; + } + + let mut numbers = Vec::new(); + for part in value.split(',') { + let part = part.trim(); + if part.is_empty() { + return None; + } + let number = part.parse::().ok()?; + numbers.push(number); + } + (!numbers.is_empty()).then_some(numbers) +} + +pub(crate) fn tidy_citation_spacing(value: &str) -> String { + let mut tidied = value.trim().to_string(); + for (from, to) in [ + (" .", "."), + (" ,", ","), + (" ;", ";"), + (" :", ":"), + (" !", "!"), + (" ?", "?"), + (" )", ")"), + ("( ", "("), + ] { + tidied = tidied.replace(from, to); + } + while tidied.contains(" ") { + tidied = tidied.replace(" ", " "); + } + tidied +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs new file mode 100644 index 0000000..d3d24ba --- /dev/null +++ b/src-tauri/src/commands.rs @@ -0,0 +1,2014 @@ +//! Every `#[tauri::command]` the renderer can invoke. +//! +//! Thin by design: each one validates its input, delegates to the domain function +//! below, and maps the result into `Cmd`. Command names are the IPC contract +//! with src/renderer/src/tauri-aether.ts and are also listed in `generate_handler!` +//! in lib.rs — adding one means touching all three. + +use super::*; + +#[tauri::command] +pub(crate) fn aether_state(state: State) -> Cmd { + Ok(lock_tabs(&state)?.state()) +} + +#[tauri::command] +pub(crate) fn aether_apps_list(state: State) -> Cmd> { + Ok(lock_tabs(&state)?.apps()) +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) fn aether_apps_activate( + app: AppHandle, + state: State, + app_id: String, +) -> Cmd<()> { + if app_id != "browser" { + return Err(format!("Unknown app: {app_id}")); + } + { + let mut tabs = lock_tabs(&state)?; + tabs.dashboard_open = false; + } + let active_tab_id = lock_tabs(&state)?.active_tab_id.clone(); + ensure_native_webview(&app, &state, &active_tab_id)?; + emit_state(&app, &state) +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) async fn aether_apps_navigate( + app: AppHandle, + state: State<'_, Backend>, + app_id: String, + url: String, +) -> Cmd<()> { + if app_id != "browser" { + return Err(format!("Unknown app: {app_id}")); + } + navigate_active_tab(&app, &state, &url).await +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) fn aether_apps_go_back( + app: AppHandle, + state: State, + app_id: String, +) -> Cmd<()> { + if app_id != "browser" { + return Err(format!("Unknown app: {app_id}")); + } + aether_tabs_go_back(app, state, String::new()) +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) fn aether_apps_go_forward( + app: AppHandle, + state: State, + app_id: String, +) -> Cmd<()> { + if app_id != "browser" { + return Err(format!("Unknown app: {app_id}")); + } + aether_tabs_go_forward(app, state, String::new()) +} + +#[tauri::command] +pub(crate) fn aether_tabs_list(state: State) -> Cmd> { + Ok(lock_tabs(&state)?.tabs()) +} + +#[tauri::command] +pub(crate) async fn aether_tabs_create( + app: AppHandle, + state: State<'_, Backend>, + input: Option, +) -> Cmd { + let settings = load_settings(&state.paths.settings_path).await?; + // No URL → open a blank start-page tab (Portals + search) rather than a search engine. + let requested_url = input.and_then(|input| input.url); + let url = match requested_url { + Some(raw_url) => normalize_url(&raw_url, &settings.browser.default_search_engine), + None => START_PAGE_URL.to_string(), + }; + let tab = ManagedTab::new("browser", &url); + let tab_id = tab.id.clone(); + let summary = tab.summary(true); + { + let mut tabs = lock_tabs(&state)?; + tabs.active_tab_id = tab.id.clone(); + tabs.active_app_id = tab.app_id.clone(); + tabs.dashboard_open = false; + tabs.tabs.push(tab); + } + ensure_native_webview(&app, &state, &tab_id)?; + emit_state(&app, &state)?; + schedule_session_save(&app); + Ok(summary) +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) fn aether_tabs_activate( + app: AppHandle, + state: State, + tab_id: String, +) -> Cmd<()> { + { + let mut tabs = lock_tabs(&state)?; + if !tabs.tabs.iter().any(|tab| tab.id == tab_id) { + return Err(format!("Unknown tab: {tab_id}")); + } + tabs.active_tab_id = tab_id.clone(); + tabs.active_app_id = "browser".to_string(); + tabs.dashboard_open = false; + } + ensure_native_webview(&app, &state, &tab_id)?; + emit_state(&app, &state)?; + schedule_session_save(&app); + Ok(()) +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) fn aether_tabs_close(app: AppHandle, state: State, tab_id: String) -> Cmd<()> { + let mut next_active_tab_id = None; + { + let mut tabs = lock_tabs(&state)?; + if tabs.tabs.len() == 1 { + return Ok(()); + } + let was_active = tabs.active_tab_id == tab_id; + tabs.tabs.retain(|tab| tab.id != tab_id); + if was_active { + if let Some(next_id) = tabs.tabs.last().map(|tab| tab.id.clone()) { + tabs.active_tab_id = next_id.clone(); + next_active_tab_id = Some(next_id); + } + } + } + close_native_webview(&app, &state, &tab_id)?; + if let Some(active_tab_id) = next_active_tab_id { + ensure_native_webview(&app, &state, &active_tab_id)?; + } else { + sync_native_webview_visibility(&app, &state)?; + } + emit_state(&app, &state)?; + schedule_session_save(&app); + Ok(()) +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) async fn aether_tabs_navigate( + app: AppHandle, + state: State<'_, Backend>, + tab_id: String, + url: String, +) -> Cmd<()> { + let settings = load_settings(&state.paths.settings_path).await?; + let target_url = { + let mut tabs = lock_tabs(&state)?; + let tab = tabs + .tabs + .iter_mut() + .find(|tab| tab.id == tab_id) + .ok_or_else(|| format!("Unknown tab: {tab_id}"))?; + tab.navigate(&url, &settings.browser.default_search_engine); + let target_url = tab.url.clone(); + tabs.active_tab_id = tab.id.clone(); + tabs.dashboard_open = false; + target_url + }; + navigate_native_webview(&app, &state, &tab_id, &target_url)?; + emit_state(&app, &state)?; + schedule_session_save(&app); + Ok(()) +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) fn aether_tabs_scroll_to_text( + app: AppHandle, + state: State, + tab_id: String, + text: String, +) -> Cmd<()> { + { + let tabs = lock_tabs(&state)?; + if !tabs.tabs.iter().any(|tab| tab.id == tab_id) { + return Err(format!("Unknown tab: {tab_id}")); + } + } + scroll_native_webview_to_text(&app, &state, &tab_id, &text) +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) fn aether_tabs_find( + app: AppHandle, + state: State, + tab_id: String, + query: Option, + action: Option, +) -> Cmd<()> { + let target_tab_id = { + let tabs = lock_tabs(&state)?; + if tab_id.is_empty() { + tabs.active_tab_id.clone() + } else if tabs.tabs.iter().any(|tab| tab.id == tab_id) { + tab_id + } else { + return Err(format!("Unknown tab: {tab_id}")); + } + }; + let action = action.as_deref().unwrap_or("find"); + find_native_webview_text(&app, &state, &target_tab_id, query.as_deref(), action) +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) fn aether_tabs_go_back( + app: AppHandle, + state: State, + tab_id: String, +) -> Cmd<()> { + let mut restore_start_page = false; + let target_tab_id = { + let mut tabs = lock_tabs(&state)?; + let tab = if tab_id.is_empty() { + tabs.active_tab_mut() + .ok_or_else(|| "No active browser tab.".to_string())? + } else { + tabs.tabs + .iter_mut() + .find(|tab| tab.id == tab_id) + .ok_or_else(|| format!("Unknown tab: {tab_id}"))? + }; + // The start page is a renderer overlay, not a native page, so the webview can't + // navigate back to it. When the previous history entry is the start page, park + // the tab back on it (its webview is kept hidden for a later forward). + if tab.can_go_back() + && tab.history.get(tab.history_index - 1).map(String::as_str) == Some(START_PAGE_URL) + { + tab.history_index -= 1; + tab.url = START_PAGE_URL.to_string(); + tab.title = "New tab".to_string(); + tab.is_loading = false; + restore_start_page = true; + } + tab.id.clone() + }; + if restore_start_page { + sync_native_webview_visibility(&app, &state)?; + } else { + navigate_native_webview_history( + &app, + &state, + &target_tab_id, + WebviewHistoryDirection::Back, + )?; + } + emit_state(&app, &state) +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) fn aether_tabs_go_forward( + app: AppHandle, + state: State, + tab_id: String, +) -> Cmd<()> { + let mut leave_start_page = false; + let target_tab_id = { + let mut tabs = lock_tabs(&state)?; + let tab = if tab_id.is_empty() { + tabs.active_tab_mut() + .ok_or_else(|| "No active browser tab.".to_string())? + } else { + tabs.tabs + .iter_mut() + .find(|tab| tab.id == tab_id) + .ok_or_else(|| format!("Unknown tab: {tab_id}"))? + }; + // Forwarding off the start page: advance to the real page whose (hidden) webview + // we kept, then reveal it instead of issuing a native history.forward(). + if tab.url == START_PAGE_URL && tab.can_go_forward() { + tab.history_index += 1; + tab.url = tab.history[tab.history_index].clone(); + tab.title = title_from_url(&tab.url); + tab.is_loading = false; + leave_start_page = true; + } + tab.id.clone() + }; + if leave_start_page { + ensure_native_webview(&app, &state, &target_tab_id)?; + } else { + navigate_native_webview_history( + &app, + &state, + &target_tab_id, + WebviewHistoryDirection::Forward, + )?; + } + emit_state(&app, &state) +} + +// Mobile-only feedback channel: the Kotlin TabsPlugin evaluates +// `window.__AETHER_TAB_EVENT__(...)` in the main webview and the renderer +// forwards the payload here, mirroring how desktop child-webview callbacks +// (on_navigation / on_page_load / on_document_title_changed) feed tab state. +#[tauri::command] +pub(crate) fn aether_tabs_report_native_event( + app: AppHandle, + state: State, + input: NativeTabEventInput, +) -> Cmd<()> { + match input.kind.as_str() { + "navigation" => { + let parked_on_start_page = { + let tabs = lock_tabs(&state)?; + tabs.tabs + .iter() + .find(|tab| tab.id == input.tab_id) + .map(|tab| tab.url == START_PAGE_URL) + .unwrap_or(true) + }; + // A tab parked on the start page keeps its (hidden) webview alive; + // ignore its stray events so the start-page sentinel survives. + if parked_on_start_page { + return Ok(()); + } + if let Some(url) = input.url.as_deref() { + update_tab_navigation_state( + &state, + &input.tab_id, + url, + input.is_loading.unwrap_or(false), + ); + } + { + let mut tabs = lock_tabs(&state)?; + if let Some(tab) = tabs.tabs.iter_mut().find(|tab| tab.id == input.tab_id) { + tab.native_can_go_back = input.can_go_back; + tab.native_can_go_forward = input.can_go_forward; + } + } + emit_state(&app, &state) + } + "title" => { + if let Some(title) = input.title.as_deref() { + update_tab_title(&state, &input.tab_id, title); + } + emit_state(&app, &state) + } + "find" => app + .emit( + AETHER_FIND_RESULT_EVENT, + FindResultPayload { + tab_id: input.tab_id, + current: input.current.unwrap_or(0), + total: input.total.unwrap_or(0), + }, + ) + .map_err(|error| error.to_string()), + _ => Ok(()), + } +} + +// Preview image (data-URI JPEG) for the mobile tab-grid switcher. Desktop +// renders live child webviews and never asks for one, so it returns None. +// Async on purpose: the Kotlin side resolves on the Android UI thread, so the +// blocking run_mobile_plugin call must sit on a tokio worker. +#[tauri::command(rename_all = "camelCase")] +pub(crate) async fn aether_tabs_thumbnail(app: AppHandle, tab_id: String) -> Cmd> { + #[cfg(target_os = "android")] + { + let response: android_tabs::ThumbnailResponse = app + .state::() + .run_for("thumbnail", android_tabs::TabPayload { tab_id: &tab_id })?; + return Ok(response.image); + } + #[allow(unreachable_code)] + { + let _ = (app, tab_id); + Ok(None) + } +} + +// System-bar/cutout insets (CSS px) for the edge-to-edge Android activity; +// zero on desktop, where the OS window frame handles this. Async for the same +// UI-thread reason as aether_tabs_thumbnail. +#[tauri::command] +pub(crate) async fn aether_layout_window_insets(app: AppHandle) -> Cmd { + #[cfg(target_os = "android")] + { + let response: android_tabs::InsetsResponse = app + .state::() + .run_for("insets", serde_json::json!({}))?; + return serde_json::to_value(response).map_err(|error| error.to_string()); + } + #[allow(unreachable_code)] + { + let _ = app; + serde_json::to_value(serde_json::json!({ + "top": 0.0, "bottom": 0.0, "left": 0.0, "right": 0.0 + })) + .map_err(|error| error.to_string()) + } +} + +// The renderer measures where Android tab WebViews belong (MobileTabView's +// The renderer measures the slot where live web content belongs (a placeholder div's +// bounding rect, CSS px) and reports it here. Both shells position their native web +// views from this, so a chrome restyle or a panel resize moves the content with it +// instead of drifting away from hardcoded offsets. +#[tauri::command(rename_all = "camelCase")] +pub(crate) fn aether_layout_set_web_content_bounds( + app: AppHandle, + state: State, + top: f64, + left: f64, + width: f64, + height: f64, +) -> Cmd<()> { + let next = WebContentBounds { + top, + left, + width, + height, + }; + { + let mut stored = state + .web_content_bounds + .lock() + .map_err(|_| "layout bounds are unavailable.".to_string())?; + // A ResizeObserver fires on every layout pass; repositioning native webviews + // for an unchanged rect causes visible flicker on desktop. + if *stored == next { + return Ok(()); + } + *stored = next; + } + sync_native_webview_visibility(&app, &state) +} + +#[tauri::command] +pub(crate) fn aether_dashboard_open(app: AppHandle, state: State) -> Cmd<()> { + { + let mut tabs = lock_tabs(&state)?; + tabs.dashboard_open = true; + } + sync_native_webview_visibility(&app, &state)?; + emit_state(&app, &state) +} + +#[tauri::command] +pub(crate) async fn aether_hub_list(state: State<'_, Backend>) -> Cmd> { + Ok(load_library(&state.paths.library_path).await?.shortcuts) +} + +#[tauri::command] +pub(crate) async fn aether_hub_create( + state: State<'_, Backend>, + input: CreateShortcutInput, +) -> Cmd { + let title = input.title.trim().to_string(); + if title.is_empty() { + return Err("Shortcut title is required.".to_string()); + } + let url = normalize_url(&input.url, "google"); + let mut data = load_library(&state.paths.library_path).await?; + let favicon = input + .favicon + .as_deref() + .map(str::trim) + .filter(|favicon| !favicon.is_empty()) + .map(str::to_string); + let theme_color = input.theme_color.as_deref().and_then(normalize_theme_color); + if let Some(existing) = data + .shortcuts + .iter_mut() + .find(|shortcut| shortcut.url == url) + { + let mut changed = false; + if existing.favicon.is_none() && favicon.is_some() { + existing.favicon = favicon; + changed = true; + } + if existing.theme_color.is_none() && theme_color.is_some() { + existing.theme_color = theme_color; + changed = true; + } + let shortcut = existing.clone(); + if changed { + save_json(&state.paths.library_path, &data).await?; + } + return Ok(shortcut); + } + let shortcut = HubShortcutSummary { + id: uuid(), + title, + host: get_tab_host(&url), + url, + created_at: now(), + favicon, + theme_color, + }; + data.shortcuts.insert(0, shortcut.clone()); + save_json(&state.paths.library_path, &data).await?; + Ok(shortcut) +} + +#[tauri::command] +pub(crate) async fn aether_hub_reorder( + state: State<'_, Backend>, + ids: Vec, +) -> Cmd> { + let mut data = load_library(&state.paths.library_path).await?; + data.shortcuts = reorder(data.shortcuts, &ids, |shortcut| &shortcut.id); + save_json(&state.paths.library_path, &data).await?; + Ok(data.shortcuts) +} + +#[tauri::command] +pub(crate) async fn aether_hub_delete(state: State<'_, Backend>, id: String) -> Cmd<()> { + let mut data = load_library(&state.paths.library_path).await?; + data.shortcuts.retain(|shortcut| shortcut.id != id); + save_json(&state.paths.library_path, &data).await +} + +#[tauri::command] +pub(crate) async fn aether_collections_list( + state: State<'_, Backend>, +) -> Cmd> { + Ok(load_library(&state.paths.library_path).await?.collections) +} + +#[tauri::command] +pub(crate) async fn aether_collections_create( + state: State<'_, Backend>, + input: CreateCollectionInput, +) -> Cmd { + let name = input.name.trim().to_string(); + if name.is_empty() { + return Err("Collection name is required.".to_string()); + } + let mut data = load_library(&state.paths.library_path).await?; + let now = now(); + let existing = data + .collections + .iter() + .map(|collection| collection.id.clone()) + .collect::>(); + let collection = CollectionSummary { + id: unique_slug(&name, &existing), + name, + description: input.description.unwrap_or_default().trim().to_string(), + icon: Some(input.icon.unwrap_or_else(|| "book".to_string())) + .map(|icon| icon.trim().to_string()) + .filter(|icon| !icon.is_empty()), + created_at: now.clone(), + updated_at: now, + capture_count: 0, + chunk_count: 0, + }; + data.collections.push(collection.clone()); + save_json(&state.paths.library_path, &data).await?; + Ok(collection) +} + +#[tauri::command] +pub(crate) async fn aether_collections_update( + state: State<'_, Backend>, + input: UpdateCollectionInput, +) -> Cmd { + let mut data = load_library(&state.paths.library_path).await?; + let collection = data + .collections + .iter_mut() + .find(|collection| collection.id == input.id) + .ok_or_else(|| "Collection not found.".to_string())?; + if let Some(name) = input.name { + let name = name.trim(); + if name.is_empty() { + return Err("Collection name is required.".to_string()); + } + collection.name = name.to_string(); + } + if let Some(description) = input.description { + collection.description = description.trim().to_string(); + } + if let Some(icon) = input.icon { + collection.icon = Some(icon.trim().to_string()).filter(|icon| !icon.is_empty()); + } + collection.updated_at = now(); + let updated = collection.clone(); + save_json(&state.paths.library_path, &data).await?; + Ok(updated) +} + +#[tauri::command] +pub(crate) async fn aether_collections_reorder( + state: State<'_, Backend>, + ids: Vec, +) -> Cmd> { + let mut data = load_library(&state.paths.library_path).await?; + data.collections = reorder(data.collections, &ids, |collection| &collection.id); + save_json(&state.paths.library_path, &data).await?; + Ok(data.collections) +} + +#[tauri::command] +pub(crate) async fn aether_collections_delete(state: State<'_, Backend>, id: String) -> Cmd<()> { + let mut library = load_library(&state.paths.library_path).await?; + library.collections.retain(|collection| collection.id != id); + library + .captures + .retain(|capture| capture.collection_id != id); + save_json(&state.paths.library_path, &library).await?; + + with_vectors_mut(&state, |vectors| { + vectors.chunks.retain(|chunk| chunk.collection_id != id); + }) + .await +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) async fn aether_collections_captures( + state: State<'_, Backend>, + collection_id: String, +) -> Cmd> { + let mut captures = load_library(&state.paths.library_path) + .await? + .captures + .into_iter() + .filter(|capture| capture.collection_id == collection_id) + .collect::>(); + captures.sort_by(|left, right| right.captured_at.cmp(&left.captured_at)); + Ok(captures) +} + +// Strict counterpart to normalize_url: capture must never silently turn a typo into +// a search-engine URL and then index the results page. Bare hosts are still +// accepted, since that is how people paste links. +pub(crate) fn capture_target_url(raw: &str) -> Cmd { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err("Enter a web address to capture.".to_string()); + } + let lowered = trimmed.to_ascii_lowercase(); + let candidate = if lowered.starts_with("http://") || lowered.starts_with("https://") { + trimmed.to_string() + } else if lowered.contains("://") { + return Err("Only http and https pages can be captured.".to_string()); + } else if !trimmed.contains(char::is_whitespace) && trimmed.contains('.') { + format!("https://{trimmed}") + } else { + return Err(format!("\"{trimmed}\" is not a web address.")); + }; + let parsed = + Url::parse(&candidate).map_err(|_| format!("\"{trimmed}\" is not a web address."))?; + if parsed.host_str().unwrap_or_default().is_empty() { + return Err(format!("\"{trimmed}\" is not a web address.")); + } + Ok(parsed.to_string()) +} + +#[tauri::command] +pub(crate) async fn aether_capture_current_page( + app: AppHandle, + state: State<'_, Backend>, + input: CaptureCurrentPageInput, +) -> Cmd { + emit_capture_progress(&app, "Reading current page", None, None); + let active_tab = { + let tabs = lock_tabs(&state)?; + if tabs.dashboard_open { + return Err("Open a website before capturing into a collection.".to_string()); + } + tabs.active_tab() + .cloned() + .ok_or_else(|| "No active browser tab.".to_string())? + }; + let app_id = active_tab.app_id.clone(); + let captured = extract_readable_active_page(&state, &active_tab).await?; + capture_page_into_collection(&app, &state, &input.collection_id, captured, &app_id).await +} + +// Captures a page ÆTHER never had to load. This is what lets sources arrive from a +// pasted link, a dropped link, or a batch of open tabs instead of only from the +// active tab, so the library can grow without the app being the default browser. +#[tauri::command] +pub(crate) async fn aether_capture_url( + app: AppHandle, + state: State<'_, Backend>, + input: CaptureUrlInput, +) -> Cmd { + let target = capture_target_url(&input.url)?; + emit_capture_progress(&app, "Fetching page", None, None); + let captured = extract_readable_page(&state.client, &target).await?; + capture_page_into_collection(&app, &state, &input.collection_id, captured, "browser").await +} + +// Bulk sibling of aether_capture_url. One bad link in a batch must not discard the +// pages that did work, so failures are reported per URL instead of aborting. +#[tauri::command] +pub(crate) async fn aether_capture_urls( + app: AppHandle, + state: State<'_, Backend>, + input: CaptureUrlsInput, +) -> Cmd { + if input.urls.is_empty() { + return Err("No links to capture.".to_string()); + } + let collection = get_collection(&state.paths.library_path, &input.collection_id).await?; + + let total = input.urls.len(); + let mut captured = Vec::new(); + let mut failures = Vec::new(); + + for (index, raw_url) in input.urls.iter().enumerate() { + emit_capture_progress( + &app, + format!("Capturing link {} of {total}", index + 1), + Some(index), + Some(total), + ); + let outcome = async { + let target = capture_target_url(raw_url)?; + let page = extract_readable_page(&state.client, &target).await?; + capture_page_into_collection(&app, &state, &input.collection_id, page, "browser").await + } + .await; + + match outcome { + Ok(result) => captured.push(result.capture), + Err(reason) => failures.push(BulkCaptureFailure { + url: raw_url.clone(), + reason, + }), + } + } + + emit_capture_progress(&app, "Finished capturing links", Some(total), Some(total)); + + Ok(BulkCaptureResult { + captured, + collection_name: collection.name, + failures, + }) +} + +// Shared tail of every capture path: chunk, embed, store vectors, update the +// library manifest. Kept in one place so the fetch-based captures cannot drift +// from the active-tab capture. +pub(crate) async fn capture_page_into_collection( + app: &AppHandle, + state: &State<'_, Backend>, + collection_id: &str, + captured: CapturedPage, + app_id: &str, +) -> Cmd { + let settings = load_settings(&state.paths.settings_path).await?; + let mut library = load_library(&state.paths.library_path).await?; + let collection = library + .collections + .iter() + .find(|collection| collection.id == collection_id) + .cloned() + .ok_or_else(|| "Collection not found.".to_string())?; + emit_capture_progress(app, "Chunking readable text", None, None); + let captured_key = normalize_capture_url_key(&captured.url); + if library.captures.iter().any(|capture| { + capture.collection_id == collection.id + && normalize_capture_url_key(&capture.url) == captured_key + }) { + return Err(format!("Page is already in {}.", collection.name)); + } + + let (chunk_size, chunk_overlap) = capture_chunk_settings(&state.paths, &settings); + let chunks = split_text(&captured.text, chunk_size, chunk_overlap); + if chunks.is_empty() { + return Err("No readable text found on that page.".to_string()); + } + emit_capture_progress( + app, + format!("Embedding {} chunks", chunks.len()), + Some(0), + Some(chunks.len()), + ); + let embeddings = local_embed_with_progress( + state, + &settings, + chunks.clone(), + Some(EmbeddingProgress { + app: app.clone(), + message: "Embedding chunks".to_string(), + }), + ) + .await?; + if embeddings.len() != chunks.len() { + return Err( + "Local embedding model returned an unexpected number of embeddings.".to_string(), + ); + } + emit_capture_progress( + app, + "Saving capture", + Some(chunks.len()), + Some(chunks.len()), + ); + + let capture_id = uuid(); + let captured_at = now(); + let records = chunks + .into_iter() + .enumerate() + .map(|(index, text)| ChunkRecord { + id: uuid(), + vector: embeddings[index].clone(), + // Assigned by push_chunks when the store accepts the record. + vector_slot: 0, + needs_reembed: false, + text, + collection_id: collection.id.clone(), + capture_id: capture_id.clone(), + title: captured.title.clone(), + url: captured.url.clone(), + app_id: app_id.to_string(), + captured_at: captured_at.clone(), + chunk_index: index, + }) + .collect::>(); + + // push_chunks assigns the sidecar slots; never extend `chunks` directly. + with_vectors_mut(state, |vectors| { + vectors.push_chunks(records.iter().cloned()); + }) + .await?; + + let capture = CaptureSummary { + id: capture_id, + collection_id: collection.id.clone(), + title: captured.title, + url: captured.url, + app_id: app_id.to_string(), + captured_at, + chunk_count: records.len(), + metadata: None, + }; + library.captures.push(capture.clone()); + if let Some(stored_collection) = library + .collections + .iter_mut() + .find(|item| item.id == collection.id) + { + stored_collection.capture_count += 1; + stored_collection.chunk_count += records.len(); + stored_collection.updated_at = capture.captured_at.clone(); + } + save_json(&state.paths.library_path, &library).await?; + + Ok(CaptureResult { + capture, + collection_name: collection.name, + }) +} + +#[tauri::command] +pub(crate) async fn aether_capture_move( + state: State<'_, Backend>, + input: MoveCaptureInput, +) -> Cmd { + let mut library = load_library(&state.paths.library_path).await?; + let now = now(); + let target_exists = library + .collections + .iter() + .any(|collection| collection.id == input.collection_id); + if !target_exists { + return Err("Target collection not found.".to_string()); + } + let capture = library + .captures + .iter_mut() + .find(|capture| capture.id == input.capture_id) + .ok_or_else(|| "Capture not found.".to_string())?; + if capture.collection_id == input.collection_id { + return Ok(capture.clone()); + } + let source_collection_id = capture.collection_id.clone(); + let chunk_count = capture.chunk_count; + capture.collection_id = input.collection_id.clone(); + let moved = capture.clone(); + for collection in &mut library.collections { + if collection.id == source_collection_id { + collection.capture_count = collection.capture_count.saturating_sub(1); + collection.chunk_count = collection.chunk_count.saturating_sub(chunk_count); + collection.updated_at = now.clone(); + } + if collection.id == input.collection_id { + collection.capture_count += 1; + collection.chunk_count += chunk_count; + collection.updated_at = now.clone(); + } + } + save_json(&state.paths.library_path, &library).await?; + + with_vectors_mut(&state, |vectors| { + for chunk in &mut vectors.chunks { + if chunk.capture_id == input.capture_id { + chunk.collection_id = input.collection_id.clone(); + } + } + }) + .await?; + Ok(moved) +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) async fn aether_capture_delete( + state: State<'_, Backend>, + capture_id: String, +) -> Cmd<()> { + let mut library = load_library(&state.paths.library_path).await?; + let deleted = library + .captures + .iter() + .find(|capture| capture.id == capture_id) + .cloned(); + library.captures.retain(|capture| capture.id != capture_id); + if let Some(deleted) = deleted { + if let Some(collection) = library + .collections + .iter_mut() + .find(|collection| collection.id == deleted.collection_id) + { + collection.capture_count = collection.capture_count.saturating_sub(1); + collection.chunk_count = collection.chunk_count.saturating_sub(deleted.chunk_count); + collection.updated_at = now(); + } + } + save_json(&state.paths.library_path, &library).await?; + with_vectors_mut(&state, |vectors| { + vectors + .chunks + .retain(|chunk| chunk.capture_id != capture_id); + }) + .await +} + +#[tauri::command] +pub(crate) async fn aether_search_collection( + state: State<'_, Backend>, + input: SearchCollectionInput, +) -> Cmd> { + search_collection(&state, input).await +} + +#[tauri::command] +pub(crate) async fn aether_semantic_trail_generate( + state: State<'_, Backend>, + input: Option, +) -> Cmd { + semantic_trail_generate( + &state, + input.unwrap_or(SemanticTrailInput { + query: None, + limit: None, + }), + ) + .await +} + +#[tauri::command] +pub(crate) async fn aether_flow_graph( + state: State<'_, Backend>, + input: Option, +) -> Cmd { + flow_graph_generate( + &state, + input.unwrap_or(FlowGraphInput { + query: None, + source_limit: None, + }), + ) + .await +} + +#[tauri::command] +pub(crate) async fn aether_air_prepare( + state: State<'_, Backend>, + input: AirDossierInput, +) -> Cmd { + air_prepare_dossier(&state, input, false).await +} + +#[tauri::command] +pub(crate) async fn aether_air_render( + state: State<'_, Backend>, + input: AirDossierInput, +) -> Cmd { + let prepared = air_prepare_dossier(&state, input, true).await?; + let output_dir = resolve_air_export_dir(&state.paths, true).await?; + let filename = air_dossier_filename(&prepared.title, &prepared.generated_at); + let path = output_dir.join(filename); + tokio::fs::write(&path, prepared.markdown_preview.as_bytes()) + .await + .map_err(|error| error.to_string())?; + Ok(AirRenderResult { + path: path.display().to_string(), + filename: path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("aether-dossier.md") + .to_string(), + title: prepared.title, + source_count: prepared.sources.len(), + rendered_at: prepared.generated_at, + }) +} + +#[tauri::command] +pub(crate) async fn aether_air_list_recent(state: State<'_, Backend>) -> Cmd> { + air_list_recent(&state.paths).await +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) fn aether_air_open(app: AppHandle, path: String) -> Cmd<()> { + let path = PathBuf::from(path); + if !path.exists() { + return Err("AiR file not found.".to_string()); + } + app.opener() + .open_path(path.display().to_string(), None::) + .map_err(|error| error.to_string()) +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) fn aether_air_reveal(app: AppHandle, path: String) -> Cmd<()> { + let path = PathBuf::from(path); + if !path.exists() { + return Err("AiR file not found.".to_string()); + } + app.opener() + .reveal_item_in_dir(path) + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub(crate) async fn aether_capture_suggest_hub( + state: State<'_, Backend>, +) -> Cmd> { + suggest_capture_hub(&state).await +} + +#[tauri::command] +pub(crate) async fn aether_chat_ask( + app: AppHandle, + state: State<'_, Backend>, + input: AskChatInput, +) -> Cmd { + let prompt = input.prompt.trim().to_string(); + if prompt.is_empty() { + return Err("Enter a question before asking Æther.".to_string()); + } + state + .generation_cancelled + .store(false, AtomicOrdering::Relaxed); + let stream = ChatStreamEmitter { + app, + request_id: input.request_id.clone().unwrap_or_else(uuid), + }; + let settings = load_settings(&state.paths.settings_path).await?; + let mut citations = if let Some(collection_id) = input.collection_id.clone() { + stream.status("Searching your knowledge hub"); + search_collection( + &state, + SearchCollectionInput { + collection_id, + query: prompt.clone(), + limit: Some(8), + }, + ) + .await? + } else { + Vec::new() + }; + + if input.include_current_page.unwrap_or(false) { + stream.status("Reading current page"); + if let Ok(active_url) = active_tab_url(&state) { + let active_tab = { + let tabs = lock_tabs(&state)?; + tabs.active_tab().cloned() + }; + let captured = if let Some(active_tab) = active_tab { + extract_readable_active_page(&state, &active_tab).await.ok() + } else { + extract_readable_page(&state.client, &active_url).await.ok() + }; + if let Some(captured) = captured { + // Give the current page fewer slots when a hub is also in play so the + // hub still contributes; let it use the full budget on its own. + let page_limit = if input.collection_id.is_some() { + 3 + } else { + chat_citation_limit() + }; + let page_citations = current_page_citations( + &state, + &settings, + captured, + &prompt, + input.collection_id.as_deref(), + page_limit, + ) + .await; + // Prepend so the current page takes priority over hub matches. + citations.splice(0..0, page_citations); + } + } + } + let citations = dedupe_citations(citations) + .into_iter() + .take(chat_citation_limit()) + .collect::>(); + + // Only the most recent turns are replayed; older ones stay on disk for reading but + // would otherwise crowd the retrieved sources out of the context window. + let thread = conversation_thread(&state.paths, input.collection_id.as_deref()).await; + let history = thread + .iter() + .rev() + .take(PROMPT_HISTORY_TURNS) + .rev() + .cloned() + .collect::>(); + + let result = local_chat( + &state, + &settings, + &prompt, + citations, + &history, + Some(stream), + ) + .await?; + + // A failed write must not discard the answer the user is already reading. + if let Err(error) = append_conversation_turn( + &state.paths, + input.collection_id.as_deref(), + ConversationTurn { + id: uuid(), + prompt: prompt.clone(), + answer: result.answer.clone(), + model: result.model.clone(), + asked_at: now(), + citations: result.citations.clone(), + metrics: result.metrics.clone(), + }, + ) + .await + { + diag_warn!("could not save conversation turn: {error}"); + } + + Ok(result) +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) async fn aether_chat_history( + state: State<'_, Backend>, + collection_id: Option, +) -> Cmd> { + Ok(conversation_thread(&state.paths, collection_id.as_deref()).await) +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) async fn aether_chat_clear_history( + state: State<'_, Backend>, + collection_id: Option, +) -> Cmd<()> { + let key = conversation_thread_key(collection_id.as_deref()); + let mut data = load_conversations(&state.paths.conversations_path).await?; + data.threads.remove(&key); + save_json(&state.paths.conversations_path, &data).await +} + +#[tauri::command] +pub(crate) async fn aether_crystallizer_generate( + state: State<'_, Backend>, + input: GenerateIcebergInput, +) -> Cmd { + let topic = input.keyword.trim().to_string(); + if topic.is_empty() { + return Err("Enter a topic before crystallizing.".to_string()); + } + state + .generation_cancelled + .store(false, AtomicOrdering::Relaxed); + let settings = load_settings(&state.paths.settings_path).await?; + local_generate_iceberg(&state, &settings, &topic).await +} + +#[tauri::command] +pub(crate) fn aether_chat_cancel(state: State) -> Cmd<()> { + state + .generation_cancelled + .store(true, AtomicOrdering::Relaxed); + Ok(()) +} + +#[tauri::command] +pub(crate) async fn aether_crystallizer_list_saved( + state: State<'_, Backend>, +) -> Cmd> { + Ok(load_icebergs(&state.paths.icebergs_path) + .await? + .icebergs + .iter() + .map(saved_iceberg_summary) + .collect()) +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) async fn aether_crystallizer_get_saved( + state: State<'_, Backend>, + id: String, +) -> Cmd { + load_icebergs(&state.paths.icebergs_path) + .await? + .icebergs + .into_iter() + .find(|iceberg| iceberg.id == id) + .ok_or_else(|| "Saved iceberg not found.".to_string()) +} + +#[tauri::command] +pub(crate) async fn aether_crystallizer_save( + state: State<'_, Backend>, + input: SaveIcebergInput, +) -> Cmd { + let title = input.title.trim().to_string(); + let keyword = input.keyword.trim().to_string(); + let model = input.model.trim().to_string(); + let generated_at = input.generated_at.trim().to_string(); + let items = normalize_saved_items(input.items); + if title.is_empty() { + return Err("Iceberg title is required.".to_string()); + } + if keyword.is_empty() { + return Err("Iceberg keyword is required.".to_string()); + } + if model.is_empty() { + return Err("Iceberg model is required.".to_string()); + } + if generated_at.is_empty() { + return Err("Iceberg generation time is required.".to_string()); + } + if items.is_empty() { + return Err("Iceberg has no usable items to save.".to_string()); + } + let now = now(); + let iceberg = SavedIceberg { + iceberg: IcebergResult { + keyword, + model, + items, + generated_at, + }, + id: uuid(), + title, + icon: normalize_iceberg_icon(input.icon), + saved_at: now.clone(), + updated_at: now, + }; + let mut data = load_icebergs(&state.paths.icebergs_path).await?; + data.icebergs.insert(0, iceberg.clone()); + save_json(&state.paths.icebergs_path, &data).await?; + Ok(iceberg) +} + +#[tauri::command] +pub(crate) async fn aether_crystallizer_reorder_saved( + state: State<'_, Backend>, + ids: Vec, +) -> Cmd> { + let mut data = load_icebergs(&state.paths.icebergs_path).await?; + data.icebergs = reorder(data.icebergs, &ids, |iceberg| &iceberg.id); + let summaries = data.icebergs.iter().map(saved_iceberg_summary).collect(); + save_json(&state.paths.icebergs_path, &data).await?; + Ok(summaries) +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) async fn aether_crystallizer_delete_saved( + state: State<'_, Backend>, + id: String, +) -> Cmd<()> { + let mut data = load_icebergs(&state.paths.icebergs_path).await?; + data.icebergs.retain(|iceberg| iceberg.id != id); + save_json(&state.paths.icebergs_path, &data).await +} + +#[tauri::command] +pub(crate) async fn aether_system_status(state: State<'_, Backend>) -> Cmd { + system_status(&state).await +} + +#[tauri::command] +pub(crate) async fn aether_system_settings(state: State<'_, Backend>) -> Cmd { + let settings = load_settings(&state.paths.settings_path).await?; + Ok(AppSettings { + browser: settings.browser, + developer_mode: settings.developer_mode, + updates: settings.updates, + appearance: settings.appearance, + }) +} + +#[tauri::command] +pub(crate) async fn aether_system_update_settings( + state: State<'_, Backend>, + input: UpdateSettingsInput, +) -> Cmd { + let mut settings = load_settings(&state.paths.settings_path).await?; + if let Some(browser) = input.browser { + if let Some(default_search_engine) = browser.default_search_engine { + settings.browser.default_search_engine = + normalize_search_engine_id(&default_search_engine); + } + } + if let Some(developer_mode) = input.developer_mode { + settings.developer_mode = developer_mode; + } + if let Some(updates) = input.updates { + if let Some(auto_check) = updates.auto_check { + settings.updates.auto_check = auto_check; + } + } + if let Some(appearance) = input.appearance { + settings.appearance = appearance; + } + save_json(&state.paths.settings_path, &settings).await?; + Ok(AppSettings { + browser: settings.browser, + developer_mode: settings.developer_mode, + updates: settings.updates, + appearance: settings.appearance, + }) +} + +#[tauri::command] +pub(crate) async fn aether_system_check_for_update( + state: State<'_, Backend>, +) -> Cmd { + let checked_at = now(); + let current_version = env!("CARGO_PKG_VERSION").to_string(); + + let request = state + .client + .get(AETHER_RELEASES_API_URL) + .header("Accept", "application/vnd.github+json") + .header("X-GitHub-Api-Version", "2022-11-28") + .header("User-Agent", "AETHER-update-checker"); + let response = match request.send().await { + Ok(response) => response, + Err(error) => { + persist_update_last_checked_at(&state.paths, &checked_at).await?; + return Ok(UpdateCheckResult { + current_version, + checked_at, + update_available: false, + latest_version: None, + latest_name: None, + release_url: None, + release_notes: None, + published_at: None, + error: Some(format!("Could not reach GitHub Releases: {error}")), + }); + } + }; + + if response.status().as_u16() == 404 { + persist_update_last_checked_at(&state.paths, &checked_at).await?; + return Ok(UpdateCheckResult { + current_version, + checked_at, + update_available: false, + latest_version: None, + latest_name: None, + release_url: None, + release_notes: None, + published_at: None, + error: Some("No published GitHub release found yet.".to_string()), + }); + } + + if !response.status().is_success() { + persist_update_last_checked_at(&state.paths, &checked_at).await?; + return Ok(UpdateCheckResult { + current_version, + checked_at, + update_available: false, + latest_version: None, + latest_name: None, + release_url: None, + release_notes: None, + published_at: None, + error: Some(format!( + "GitHub Releases returned HTTP {}.", + response.status().as_u16() + )), + }); + } + + let release = match response.json::().await { + Ok(release) => release, + Err(error) => { + persist_update_last_checked_at(&state.paths, &checked_at).await?; + return Ok(UpdateCheckResult { + current_version, + checked_at, + update_available: false, + latest_version: None, + latest_name: None, + release_url: None, + release_notes: None, + published_at: None, + error: Some(format!("Could not read GitHub release metadata: {error}")), + }); + } + }; + let latest_version = release_version_from_tag(&release.tag_name); + let update_available = version_is_newer(&latest_version, ¤t_version); + persist_update_last_checked_at(&state.paths, &checked_at).await?; + + Ok(UpdateCheckResult { + current_version, + checked_at, + update_available, + latest_version: Some(latest_version), + latest_name: release.name.or(Some(release.tag_name)), + release_url: Some(release.html_url), + release_notes: release + .body + .map(|body| body.trim().chars().take(1400).collect::()) + .filter(|body| !body.is_empty()), + published_at: release.published_at, + error: None, + }) +} + +// Empty unless a real minisign public key has been put in tauri.conf.json (see +// docs/SIGNING.md). Unsigned local builds keep working; they just report +// `unconfigured` instead of downloading something they could never verify. +#[cfg(desktop)] +pub(crate) fn updater_pubkey(app: &AppHandle) -> Option { + updater_pubkey_from_config(app.config().plugins.0.get("updater")) +} + +// Split out so the placeholder-versus-real-key branch is testable without an +// AppHandle. Whitespace is trimmed because the key is pasted in by hand during +// signing setup, and a trailing newline is the obvious way to get a "configured" +// build that fails every signature check. +#[cfg(desktop)] +pub(crate) fn updater_pubkey_from_config(updater: Option<&serde_json::Value>) -> Option { + updater + .and_then(|updater| updater.get("pubkey")) + .and_then(|pubkey| pubkey.as_str()) + .map(str::trim) + .filter(|pubkey| !pubkey.is_empty()) + .map(str::to_string) +} + +// Replacing the app in place only works where the install is a self-contained +// bundle we own. A .deb or .rpm is owned by the system package manager, and +// overwriting its files from inside the running process would corrupt it — those +// installs update through apt/dnf or a fresh download. +#[cfg(desktop)] +pub(crate) fn updater_install_support(app: &AppHandle) -> Result<(), String> { + let _ = app; + #[cfg(target_os = "linux")] + if app.env().appimage.is_none() { + return Err( + "This Linux install is managed by your package manager. Update it with apt \ + (or download the latest .deb) rather than from inside ÆTHER." + .to_string(), + ); + } + Ok(()) +} + +// Downloads, signature-verifies, and installs the newest release. Deliberately +// separate from aether_system_check_for_update: that one reads the GitHub API for +// human-readable release notes, this one reads the signed updater manifest. They +// can disagree — a release whose latest.json failed to upload is visible to the +// first and invisible to the second — and the UI needs to say so rather than +// silently do nothing. +#[tauri::command] +pub(crate) async fn aether_system_install_update(app: AppHandle) -> Cmd { + #[cfg(not(desktop))] + { + let _ = app; + return Ok(UpdateInstallResult::new( + UpdateInstallStatus::Unsupported, + "Mobile builds update through the app store, not from inside ÆTHER.", + )); + } + + #[cfg(desktop)] + aether_install_update_desktop(app).await +} + +#[cfg(desktop)] +pub(crate) async fn aether_install_update_desktop(app: AppHandle) -> Cmd { + use tauri_plugin_updater::UpdaterExt; + + let Some(pubkey) = updater_pubkey(&app) else { + return Ok(UpdateInstallResult::new( + UpdateInstallStatus::Unconfigured, + "This build has no update signing key, so ÆTHER cannot verify a download. \ + Install the new version from the releases page instead.", + )); + }; + + if let Err(message) = updater_install_support(&app) { + return Ok(UpdateInstallResult::new( + UpdateInstallStatus::Unsupported, + message, + )); + } + + let updater = app + .updater_builder() + .pubkey(pubkey) + .build() + .map_err(|error| format!("Could not start the updater: {error}"))?; + + let update = match updater.check().await { + Ok(Some(update)) => update, + Ok(None) => { + return Ok(UpdateInstallResult::new( + UpdateInstallStatus::Unavailable, + "The update manifest has no newer signed build for this platform yet.", + )) + } + // A release can exist while carrying no artifact for this os/arch — ÆTHER + // publishes no ARM Linux AppImage, for one. That is a gap in the release, + // not a failure the user can act on, so it reads as unavailable rather + // than as an error about a manifest they have never heard of. + Err(tauri_plugin_updater::Error::TargetNotFound(target)) => { + return Ok(UpdateInstallResult::new( + UpdateInstallStatus::Unavailable, + format!("The latest release has no build for {target}."), + )) + } + Err(tauri_plugin_updater::Error::TargetsNotFound(targets)) => { + return Ok(UpdateInstallResult::new( + UpdateInstallStatus::Unavailable, + format!( + "The latest release has no build for this platform ({}).", + targets.join(" or ") + ), + )) + } + Err(error) => { + return Err(format!("Could not read the update manifest: {error}")); + } + }; + + let version = update.version.clone(); + let progress_app = app.clone(); + let mut downloaded_bytes: u64 = 0; + + update + .download_and_install( + move |chunk_length, total_bytes| { + downloaded_bytes += chunk_length as u64; + let _ = progress_app.emit( + AETHER_UPDATE_PROGRESS_EVENT, + UpdateInstallProgress { + downloaded_bytes, + total_bytes, + done: false, + }, + ); + }, + { + let app = app.clone(); + move || { + let _ = app.emit( + AETHER_UPDATE_PROGRESS_EVENT, + UpdateInstallProgress { + downloaded_bytes: 0, + total_bytes: None, + done: true, + }, + ); + } + }, + ) + .await + .map_err(|error| format!("Could not install ÆTHER {version}: {error}"))?; + + Ok(UpdateInstallResult { + status: UpdateInstallStatus::Installed.as_str().to_string(), + version: Some(version.clone()), + needs_restart: true, + message: format!("ÆTHER {version} is installed and starts on the next launch."), + }) +} + +// Tauri's own AppHandle::restart() cannot be used here, on two counts. +// +// Commands run off the main thread, so restart() takes its threaded path: it asks +// the runtime to exit and waits for RunEvent::ExitRequested to carry out the +// relaunch. Our ExitRequested handler answers with force_exit(), so the process +// dies before the relaunch ever happens — the user clicks Restart and the app +// simply quits. +// +// Its fallback re-exec is no better: tauri::process::restart ends in +// std::process::exit, which runs the C++ static destructors that force_exit() +// exists specifically to avoid (see the comment there). That would spawn the new +// ÆTHER and then crash the old one on the way out. +// +// So spawn the replacement ourselves and leave the same way every other quit does. +#[cfg(desktop)] +pub(crate) fn relaunch_after_update(app: &AppHandle) -> ! { + let env = app.env(); + let binary = tauri::process::current_binary(&env); + + // Relaunch the bundle, not the inner binary: `open` re-registers the freshly + // written .app with Launch Services, which is what the Dock and Gatekeeper + // know about. `-n` is required — without it, `open` sees the still-running old + // process, activates that instead, and nothing restarts once it exits. + #[cfg(target_os = "macos")] + if let Ok(binary) = binary.as_ref() { + let bundle = binary + .parent() + .and_then(|macos| macos.parent()) + .and_then(|contents| contents.parent()); + if let Some(bundle) = bundle.filter(|path| path.extension().is_some_and(|ext| ext == "app")) + { + let _ = std::process::Command::new("/usr/bin/open") + .arg("-n") + .arg(bundle) + .spawn(); + force_exit(); + } + } + + if let Ok(binary) = binary { + let _ = std::process::Command::new(binary) + .args(env.args_os.iter().skip(1)) + .spawn(); + } + force_exit(); +} + +// Split from the install so the user chooses when to lose their current window. +// Session state is already persisted per-action, but an update is not a good +// moment to surprise someone mid-read. +#[tauri::command] +pub(crate) async fn aether_system_relaunch(app: AppHandle) -> Cmd<()> { + #[cfg(desktop)] + { + save_window_geometry_now(&app); + relaunch_after_update(&app); + } + #[cfg(not(desktop))] + { + let _ = app; + Err("Restarting from inside the app is only supported on desktop.".to_string()) + } +} + +#[tauri::command] +pub(crate) async fn aether_system_update_models( + state: State<'_, Backend>, + input: UpdateModelsInput, +) -> Cmd { + let mut settings = load_settings(&state.paths.settings_path).await?; + if let Some(model) = input.embedding_model { + settings.local_model.embedding_model = + Some(model.trim().to_string()).filter(|item| !item.is_empty()); + } + if let Some(model) = input.chat_model { + settings.local_model.chat_model = + Some(model.trim().to_string()).filter(|item| !item.is_empty()); + } + save_json(&state.paths.settings_path, &settings).await?; + system_status(&state).await +} + +#[tauri::command] +pub(crate) async fn aether_system_download_models( + app: AppHandle, + state: State<'_, Backend>, + input: DownloadModelsInput, +) -> Cmd { + download_managed_models(&app, &state, input).await?; + system_status(&state).await +} + +/// Recent diagnostics, newest first, for the Settings panel. In-memory only — the +/// on-disk log is for export, not for rendering. +#[tauri::command] +pub(crate) async fn aether_system_diagnostics() -> Cmd> { + Ok(diagnostics::recent()) +} + +/// Copies the diagnostics log somewhere the user can attach it to a bug report, and +/// reveals it. Nothing leaves the machine until they do that themselves — which is +/// the whole reason this is an export rather than an upload. +#[tauri::command] +pub(crate) async fn aether_system_export_diagnostics( + app: AppHandle, + state: State<'_, Backend>, +) -> Cmd { + let source = diagnostics::diagnostics_log_path() + .ok_or_else(|| "The diagnostics log has not been opened yet.".to_string())?; + + let exported_at = now(); + let filename = format!("aether-diagnostics-{}.log", exported_at.replace(':', "-")); + let target = state.paths.exports_path.join(&filename); + ensure_parent_dir(&target).await?; + + // Written from the file rather than the in-memory buffer so an export covers + // earlier sessions too, which is usually where the interesting failure is. + let contents = tokio::fs::read(&source).await.unwrap_or_default(); + let byte_size = contents.len() as u64; + tokio::fs::write(&target, contents) + .await + .map_err(|error| format!("Could not write the diagnostics export: {error}"))?; + + if let Err(error) = app.opener().reveal_item_in_dir(&target) { + diag_warn!("exported diagnostics but could not reveal them: {error}"); + } + + Ok(DiagnosticsExportResult { + path: target.display().to_string(), + filename, + byte_size, + exported_at, + }) +} + +// ÆTHER keeps no cloud copy, so a user-owned export is the only real backup. This +// snapshots every store into one timestamped folder the user can copy anywhere. +#[tauri::command] +pub(crate) async fn aether_system_export_library( + app: AppHandle, + state: State<'_, Backend>, +) -> Cmd { + let paths = &state.paths; + let exported_at = now(); + let folder_name = format!("aether-export-{}", exported_at.replace(':', "-")); + let target_dir = paths.exports_path.join(&folder_name); + tokio::fs::create_dir_all(&target_dir) + .await + .map_err(|error| error.to_string())?; + + // chunks.json holds only metadata; without the binary sidecar beside it the + // export would restore a library whose sources cannot be searched. + // + // `chunks.v1.json` is deliberately not exported. It holds only vectors from a + // superseded embedding model, and the text they belong to is already in + // chunks.json — so a restore plus a re-index reproduces everything it contains, + // for none of the ~5x size. + let chunks_vec_path = vector_data_path(&paths.chunks_path); + let sources: [(&PathBuf, &str); 7] = [ + (&paths.library_path, "library.json"), + (&paths.chunks_path, "chunks.json"), + (&chunks_vec_path, "chunks.vec"), + (&paths.settings_path, "settings.json"), + (&paths.icebergs_path, "icebergs.json"), + (&paths.conversations_path, "conversations.json"), + (&paths.session_path, "session.json"), + ]; + + let mut files = Vec::new(); + let mut byte_size = 0_u64; + for (source, name) in sources { + if !tokio::fs::try_exists(source).await.unwrap_or(false) { + continue; + } + let copied = tokio::fs::copy(source, target_dir.join(name)) + .await + .map_err(|error| format!("Could not export {name}: {error}"))?; + byte_size += copied; + files.push(name.to_string()); + } + + if files.is_empty() { + // Leaving an empty folder behind would look like a successful export. + let _ = tokio::fs::remove_dir(&target_dir).await; + return Err("There is nothing to export yet.".to_string()); + } + + let library = load_library(&paths.library_path).await.unwrap_or_default(); + let capture_count = library.captures.len(); + let chunk_count = library + .collections + .iter() + .map(|collection| collection.chunk_count) + .sum::(); + + let manifest = serde_json::json!({ + "app": "aether", + "appVersion": app.package_info().version.to_string(), + "exportedAt": exported_at, + "files": files, + "captureCount": capture_count, + "chunkCount": chunk_count, + "collectionCount": library.collections.len(), + }); + let manifest_raw = + serde_json::to_string_pretty(&manifest).map_err(|error| error.to_string())?; + tokio::fs::write( + target_dir.join("manifest.json"), + format!("{manifest_raw}\n"), + ) + .await + .map_err(|error| error.to_string())?; + files.push("manifest.json".to_string()); + + // Opening the folder is the expected payoff of an explicit export click, but a + // desktop without a file manager should not fail the export. + if let Err(error) = app.opener().reveal_item_in_dir(&target_dir) { + diag_warn!("exported but could not reveal folder: {error}"); + } + + Ok(LibraryExportResult { + path: target_dir.display().to_string(), + exported_at, + files, + capture_count, + chunk_count, + byte_size, + }) +} + +// Deliberately a separate command rather than a field on SystemStatus: answering it +// forces the vector store to load, and SystemStatus runs at startup where that would +// add a full parse of the metadata plus the sidecar to launch. Settings asks for this +// only when opened, which is also the only place the re-index button lives. +#[tauri::command] +pub(crate) async fn aether_library_index_status( + state: State<'_, Backend>, +) -> Cmd { + with_vectors_read(&state, |vectors| LibraryIndexStatus { + dim: vectors.dim, + embedded: vectors.embedded_count() as usize, + pending_reembed: vectors.pending_reembed_count(), + }) + .await +} + +// Re-embeds every retained chunk with the loaded embedding model. This is the only +// way out of a store whose vectors came from a different model: the widths cannot be +// compared, so those chunks are invisible to search until they are embedded again. +// Chunk text is kept in the store, so this is local compute — no page is refetched. +#[tauri::command] +pub(crate) async fn aether_library_reindex( + app: AppHandle, + state: State<'_, Backend>, +) -> Cmd { + let settings = load_settings(&state.paths.settings_path).await?; + + // Snapshot ids and text without holding the lock: embedding takes minutes on a + // large library, and blocking every capture for its duration is not acceptable. + let pending = with_vectors_read(&state, |vectors| { + vectors + .chunks + .iter() + .map(|chunk| (chunk.id.clone(), chunk.text.clone())) + .collect::>() + }) + .await?; + + if pending.is_empty() { + return Err("There is nothing to re-index yet.".to_string()); + } + + let total = pending.len(); + emit_capture_progress(&app, "Re-indexing library", Some(0), Some(total)); + + let mut vectors_by_id: HashMap> = HashMap::with_capacity(total); + // Batched so progress advances steadily and peak memory stays bounded, rather than + // handing the runtime every chunk in the library at once. + for (batch_index, batch) in pending.chunks(REINDEX_BATCH_SIZE).enumerate() { + let inputs = batch + .iter() + .map(|(_, text)| text.clone()) + .collect::>(); + let embeddings = local_embed_with_progress( + &state, + &settings, + inputs, + Some(EmbeddingProgress { + app: app.clone(), + message: "Re-indexing library".to_string(), + }), + ) + .await?; + if embeddings.len() != batch.len() { + return Err( + "Local embedding model returned an unexpected number of embeddings.".to_string(), + ); + } + for ((id, _), vector) in batch.iter().zip(embeddings) { + vectors_by_id.insert(id.clone(), vector); + } + emit_capture_progress( + &app, + "Re-indexing library", + Some(((batch_index + 1) * REINDEX_BATCH_SIZE).min(total)), + Some(total), + ); + } + + let dim = vectors_by_id + .values() + .map(Vec::len) + .max() + .filter(|dim| *dim > 0) + .ok_or_else(|| "The embedding model returned no usable vectors.".to_string())?; + + let mut guard = state.vectors.write().await; + if guard.is_none() { + *guard = Some(load_vectors(&state.paths.chunks_path).await?); + } + let data = guard.as_mut().expect("vector store cache"); + + // Rebuild rather than patch: the store's width is changing, so every slot has to + // be reassigned against the new stride. + data.dim = dim; + data.next_slot = 0; + let existing = std::mem::take(&mut data.chunks); + // Matched by id, so chunks captured while the embedding ran keep their own vectors + // instead of being matched to the wrong text by position. + data.push_chunks(existing.into_iter().map(|mut chunk| { + if let Some(vector) = vectors_by_id.remove(&chunk.id) { + chunk.vector = vector; + } + chunk.needs_reembed = false; + chunk + })); + + let embedded = data.embedded_count() as usize; + let still_pending = data.pending_reembed_count(); + write_vector_sidecar(&state.paths.chunks_path, data, 0).await?; + save_vector_metadata(&state.paths.chunks_path, data).await?; + drop(guard); + + emit_capture_progress(&app, "Re-index complete", Some(total), Some(total)); + Ok(LibraryReindexResult { + embedded, + still_pending, + dim, + reindexed_at: now(), + }) +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) fn aether_system_open_external_url(app: AppHandle, url: String) -> Cmd<()> { + let parsed = Url::parse(url.trim()).map_err(|_| "Invalid release URL.".to_string())?; + match parsed.scheme() { + "https" | "http" => app + .opener() + .open_url(parsed.to_string(), None::) + .map_err(|error| error.to_string()), + _ => Err("Only web URLs can be opened from Settings.".to_string()), + } +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) fn aether_layout_set_panel_collapsed( + app: AppHandle, + state: State, + collapsed: bool, +) -> Cmd<()> { + { + let mut tabs = lock_tabs(&state)?; + tabs.panel_collapsed = collapsed; + } + sync_native_webview_visibility(&app, &state)?; + emit_state(&app, &state) +} + +#[tauri::command(rename_all = "camelCase")] +pub(crate) fn aether_layout_set_modal_overlay_open( + app: AppHandle, + state: State, + open: bool, +) -> Cmd<()> { + { + let mut tabs = lock_tabs(&state)?; + tabs.modal_overlay_open = open; + } + sync_native_webview_visibility(&app, &state) +} + +#[tauri::command] +pub(crate) fn aether_layout_show_status_toast(input: StatusToastInput) -> Cmd<()> { + let _ = (input.message, input.tone, input.duration_ms); + Ok(()) +} diff --git a/src-tauri/src/diagnostics.rs b/src-tauri/src/diagnostics.rs new file mode 100644 index 0000000..dfc1e71 --- /dev/null +++ b/src-tauri/src/diagnostics.rs @@ -0,0 +1,189 @@ +//! A local diagnostics log the user can read and export. +//! +//! ÆTHER ships CPU-only Windows and Linux builds that are never run before +//! release, and it collects no telemetry — so without this, a failure on those +//! platforms produces a line on a stderr nobody will ever see. The point is a +//! feedback loop that costs the privacy promise nothing: the log is written only +//! to the user's own machine, is visible in Settings, and leaves it only when the +//! user presses Export. +//! +//! What is deliberately not recorded: page text, captured content, search queries, +//! and answers. Entries are operational — what failed and where — so an exported +//! log cannot become an accidental transcript of what someone was reading. + +use super::*; +use std::collections::VecDeque; +use std::fmt::Write as _; +use std::sync::OnceLock; + +/// Kept in memory so Settings can show recent entries without reading the file +/// back. Bounded because a tight failure loop must not grow the process. +const MAX_ENTRIES: usize = 400; +/// The on-disk log is truncated to the newest half once it passes this. A log that +/// grows without limit is a bug report nobody can attach. +const MAX_LOG_BYTES: u64 = 512 * 1024; + +pub(crate) const DIAGNOSTICS_DIR: &str = "aether-diagnostics"; +pub(crate) const DIAGNOSTICS_FILE: &str = "aether.log"; + +#[derive(Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum DiagnosticLevel { + Info, + Warn, + Error, +} + +impl DiagnosticLevel { + fn as_str(self) -> &'static str { + match self { + Self::Info => "info", + Self::Warn => "warn", + Self::Error => "error", + } + } +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DiagnosticEntry { + pub(crate) at: String, + pub(crate) level: DiagnosticLevel, + pub(crate) message: String, +} + +fn buffer() -> &'static Mutex> { + static BUFFER: OnceLock>> = OnceLock::new(); + BUFFER.get_or_init(|| Mutex::new(VecDeque::with_capacity(MAX_ENTRIES))) +} + +// Set once during setup. Held separately from Backend because the first entries +// can be recorded while the app state is still being built — session restore runs +// before anything else and is one of the paths most worth logging. +fn log_path() -> &'static OnceLock { + static LOG_PATH: OnceLock = OnceLock::new(); + &LOG_PATH +} + +pub(crate) fn set_log_path(app_data_dir: &Path) { + let _ = log_path().set(app_data_dir.join(DIAGNOSTICS_DIR).join(DIAGNOSTICS_FILE)); +} + +pub(crate) fn record(level: DiagnosticLevel, message: impl Into) { + let message = message.into(); + let entry = DiagnosticEntry { + at: now(), + level, + message, + }; + + // Still goes to stderr: `tauri dev` and a terminal launch are where this is + // most useful, and losing that would be a downgrade. Must stay a raw + // eprintln! — routing it through a diag_* macro recurses into this function. + eprintln!("aether [{}] {}", level.as_str(), entry.message); + + if let Ok(mut entries) = buffer().lock() { + if entries.len() == MAX_ENTRIES { + entries.pop_front(); + } + entries.push_back(entry.clone()); + } + + append_to_file(&entry); +} + +// Synchronous and best-effort by design. These are rare (failures, migrations), +// and a diagnostics write must never be able to fail an operation or block on an +// async runtime that may not exist on the calling thread. +fn append_to_file(entry: &DiagnosticEntry) { + let Some(path) = log_path().get() else { + return; + }; + write_entry(path, entry); +} + +// Split from append_to_file so the rollover and formatting can be tested without +// the process-wide OnceLock, which only one test could ever claim. +pub(crate) fn write_entry(path: &Path, entry: &DiagnosticEntry) { + if let Some(parent) = path.parent() { + if fs::create_dir_all(parent).is_err() { + return; + } + } + + if let Ok(metadata) = fs::metadata(path) { + if metadata.len() > MAX_LOG_BYTES { + truncate_to_newest_half(path); + } + } + + let mut line = String::new(); + let _ = writeln!( + line, + "{} [{}] {}", + entry.at, + entry.level.as_str(), + entry.message + ); + + use std::io::Write as _; + if let Ok(mut file) = fs::OpenOptions::new().create(true).append(true).open(path) { + let _ = file.write_all(line.as_bytes()); + } +} + +// Drops the oldest half rather than the whole file: a wrapping log that discards +// everything on rollover tends to be empty exactly when someone needs it. +fn truncate_to_newest_half(path: &Path) { + let Ok(existing) = fs::read_to_string(path) else { + return; + }; + let lines: Vec<&str> = existing.lines().collect(); + let keep = lines.split_at(lines.len() / 2).1.join("\n"); + let _ = fs::write(path, format!("{keep}\n")); +} + +pub(crate) fn recent() -> Vec { + buffer() + .lock() + .map(|entries| entries.iter().rev().cloned().collect()) + .unwrap_or_default() +} + +pub(crate) fn diagnostics_log_path() -> Option { + log_path().get().cloned() +} + +/// Records at warn level. Replaces the bare `eprintln!` calls that used to go to a +/// stderr nobody reads. +macro_rules! diag_warn { + ($($arg:tt)*) => { + crate::diagnostics::record( + crate::diagnostics::DiagnosticLevel::Warn, + format!($($arg)*), + ) + }; +} + +/// Records at error level: something the user's library or a core action depends on. +macro_rules! diag_error { + ($($arg:tt)*) => { + crate::diagnostics::record( + crate::diagnostics::DiagnosticLevel::Error, + format!($($arg)*), + ) + }; +} + +/// Records at info level. For state changes worth seeing in a bug report — +/// migrations, compactions, recoveries — not for routine activity. +macro_rules! diag_info { + ($($arg:tt)*) => { + crate::diagnostics::record( + crate::diagnostics::DiagnosticLevel::Info, + format!($($arg)*), + ) + }; +} + +pub(crate) use {diag_error, diag_info, diag_warn}; diff --git a/src-tauri/src/extract.rs b/src-tauri/src/extract.rs new file mode 100644 index 0000000..e297769 --- /dev/null +++ b/src-tauri/src/extract.rs @@ -0,0 +1,208 @@ +//! Turning a live page into capturable text: a snapshot from the webview where one +//! exists, an HTTP fetch and `scraper` pass where it does not. + +use super::*; + +pub(crate) async fn extract_readable_active_page( + state: &State<'_, Backend>, + active_tab: &ManagedTab, +) -> Cmd { + #[cfg(desktop)] + { + if let Ok(page) = extract_readable_page_from_webview(state, active_tab).await { + return Ok(page); + } + } + + #[cfg(target_os = "android")] + { + match extract_readable_page_from_android(active_tab) { + Ok(page) => return Ok(page), + Err(_) => {} + } + } + + extract_readable_page(&state.client, &active_tab.url).await +} + +// Android counterpart of extract_readable_page_from_webview: the Kotlin +// TabsPlugin's `snapshot` command reads the live DOM through +// evaluateJavascript's value callback, so logged-in and JS-rendered pages +// capture correctly instead of falling back to an anonymous HTTP re-fetch. +#[cfg(target_os = "android")] +pub(crate) fn extract_readable_page_from_android(active_tab: &ManagedTab) -> Cmd { + let response: android_tabs::SnapshotResponse = android_tabs::run_for_global( + "snapshot", + android_tabs::TabPayload { + tab_id: &active_tab.id, + }, + )?; + let payload = response.payload.trim(); + if payload.is_empty() || payload == "null" { + return Err("Unable to read the active page.".to_string()); + } + let snapshot = parse_page_snapshot(payload)?; + snapshot_to_captured_page(snapshot, &active_tab.title) +} + +#[cfg(desktop)] +pub(crate) async fn extract_readable_page_from_webview( + state: &State<'_, Backend>, + active_tab: &ManagedTab, +) -> Cmd { + let webview = state + .webviews + .lock() + .map_err(|_| "Æther webviews are unavailable.".to_string())? + .views + .get(&active_tab.id) + .cloned() + .ok_or_else(|| "Active browser webview is not ready.".to_string())?; + let script = r#"(() => { + const clone = document.documentElement.cloneNode(true); + clone.querySelectorAll('script, style, noscript, iframe, form, nav, footer, svg').forEach((node) => node.remove()); + return { + html: '' + clone.outerHTML, + url: location.href, + title: document.title, + description: document.querySelector('meta[name="description"]')?.getAttribute('content') || '', + bodyText: document.body?.innerText || '' + }; + })()"#; + let (sender, receiver) = tokio::sync::oneshot::channel::(); + let sender = Arc::new(Mutex::new(Some(sender))); + webview + .eval_with_callback(script, { + let sender = Arc::clone(&sender); + move |payload| { + if let Ok(mut sender) = sender.lock() { + if let Some(sender) = sender.take() { + let _ = sender.send(payload); + } + } + } + }) + .map_err(|error| error.to_string())?; + let payload = tokio::time::timeout(Duration::from_secs(5), receiver) + .await + .map_err(|_| "Timed out reading the active page.".to_string())? + .map_err(|_| "Unable to read the active page.".to_string())?; + let snapshot = parse_page_snapshot(&payload)?; + snapshot_to_captured_page(snapshot, &active_tab.title) +} + +pub(crate) fn parse_page_snapshot(payload: &str) -> Cmd { + parse_json_payload::(payload) +} + +pub(crate) fn parse_json_payload(payload: &str) -> Cmd { + let value = + serde_json::from_str::(payload).map_err(|error| error.to_string())?; + if let Some(inner) = value.as_str() { + serde_json::from_str::(inner).map_err(|error| error.to_string()) + } else { + serde_json::from_value::(value).map_err(|error| error.to_string()) + } +} + +pub(crate) fn snapshot_to_captured_page( + snapshot: BrowserPageSnapshot, + fallback_title: &str, +) -> Cmd { + let url = snapshot + .url + .filter(|url| !url.trim().is_empty()) + .ok_or_else(|| "Unable to read the active page.".to_string())?; + let parsed_document = snapshot + .html + .as_ref() + .map(|html| Html::parse_document(html)); + let title = snapshot + .title + .filter(|title| !title.trim().is_empty()) + .or_else(|| { + parsed_document + .as_ref() + .and_then(|document| select_first_text(document, "title")) + }) + .unwrap_or_else(|| fallback_title.to_string()); + let description = snapshot.description.unwrap_or_else(|| { + parsed_document + .as_ref() + .and_then(|document| select_meta_content(document, "description")) + .unwrap_or_default() + }); + let body_text = snapshot.body_text.unwrap_or_else(|| { + parsed_document + .as_ref() + .map(select_body_text) + .unwrap_or_default() + }); + let text = normalize_captured_text(&format!("{title}\n\n{description}\n\n{body_text}")); + + if text.len() < MIN_CAPTURE_TEXT_LENGTH { + return Err("This page does not contain enough readable text to capture.".to_string()); + } + + Ok(CapturedPage { title, url, text }) +} + +pub(crate) async fn extract_readable_page(client: &Client, url: &str) -> Cmd { + let parsed = Url::parse(url).map_err(|_| "Unable to read the active page URL.".to_string())?; + if parsed.scheme() != "http" && parsed.scheme() != "https" { + return Err("Only http and https pages can be captured in the Tauri build.".to_string()); + } + let response = client + .get(url) + .timeout(Duration::from_secs(20)) + .send() + .await + .map_err(|error| error.to_string())?; + if !response.status().is_success() { + return Err(format!("Unable to fetch page: {}", response.status())); + } + let html = response.text().await.map_err(|error| error.to_string())?; + let document = Html::parse_document(&html); + let title = select_first_text(&document, "title") + .filter(|title| !title.is_empty()) + .unwrap_or_else(|| title_from_url(url)); + let description = select_meta_content(&document, "description").unwrap_or_default(); + let body_text = select_body_text(&document); + let text = normalize_captured_text(&format!("{title}\n\n{description}\n\n{body_text}")); + if text.len() < MIN_CAPTURE_TEXT_LENGTH { + return Err("This page does not contain enough readable text to capture.".to_string()); + } + Ok(CapturedPage { + title, + url: url.to_string(), + text, + }) +} + +pub(crate) fn select_first_text(document: &Html, selector: &str) -> Option { + let selector = Selector::parse(selector).ok()?; + document + .select(&selector) + .next() + .map(|node| node.text().collect::>().join(" ").trim().to_string()) +} + +pub(crate) fn select_meta_content(document: &Html, name: &str) -> Option { + let selector = Selector::parse(&format!("meta[name=\"{name}\"]")).ok()?; + document + .select(&selector) + .next() + .and_then(|node| node.value().attr("content")) + .map(|value| value.trim().to_string()) +} + +pub(crate) fn select_body_text(document: &Html) -> String { + let selector = Selector::parse("body").expect("body selector"); + document + .select(&selector) + .flat_map(|node| node.text()) + .map(str::trim) + .filter(|text| !text.is_empty()) + .collect::>() + .join(" ") +} diff --git a/src-tauri/src/flow.rs b/src-tauri/src/flow.rs new file mode 100644 index 0000000..63bfd39 --- /dev/null +++ b/src-tauri/src/flow.rs @@ -0,0 +1,285 @@ +//! The Flow query graph: hubs and sources as nodes, semantic similarity as edges. + +use super::*; + +pub(crate) async fn flow_graph_generate( + state: &State<'_, Backend>, + input: FlowGraphInput, +) -> Cmd { + let query = input.query.unwrap_or_default().trim().to_string(); + let source_limit = input + .source_limit + .unwrap_or(DEFAULT_FLOW_GRAPH_SOURCE_LIMIT) + .clamp(1, MAX_FLOW_GRAPH_SOURCE_LIMIT); + let library = load_library(&state.paths.library_path).await?; + let collection_names = library + .collections + .iter() + .map(|collection| (collection.id.clone(), collection.name.clone())) + .collect::>(); + let chunks = with_vectors_read(state, |vectors| vectors.chunks.clone()).await?; + let mut chunks_by_capture = HashMap::>::new(); + for chunk in chunks { + chunks_by_capture + .entry(chunk.capture_id.clone()) + .or_default() + .push(chunk); + } + + let mut captures = library.captures.clone(); + captures.sort_by(|left, right| right.captured_at.cmp(&left.captured_at)); + let indexed_source_count = captures + .iter() + .filter(|capture| chunks_by_capture.contains_key(&capture.id)) + .count(); + let mut sources = Vec::::new(); + for capture in captures { + if sources.len() >= source_limit { + break; + } + let Some(chunks) = chunks_by_capture.get(&capture.id) else { + continue; + }; + let Some(vector) = average_flow_source_vector(chunks) else { + continue; + }; + let collection_name = collection_names + .get(&capture.collection_id) + .cloned() + .unwrap_or_else(|| "Knowledge Hub".to_string()); + let excerpt = chunks + .iter() + .max_by_key(|chunk| chunk.text.chars().count()) + .map(|chunk| semantic_trail_excerpt(&chunk.text, 300)) + .unwrap_or_default(); + sources.push(FlowSourceCandidate { + capture, + collection_name, + vector, + excerpt, + }); + } + + let query_scores = if query.is_empty() || sources.is_empty() { + HashMap::new() + } else { + let settings = load_settings(&state.paths.settings_path).await?; + let query_vector = local_embed_query(state, &settings, query.clone()).await?; + sources + .iter() + .map(|source| { + let distance = cosine_distance(&query_vector, &source.vector); + ( + flow_source_node_id(&source.capture.id), + semantic_score_from_distance(distance), + ) + }) + .filter(|(_, score)| score.is_finite()) + .collect::>() + }; + + let mut nodes = Vec::::new(); + if !query.is_empty() { + nodes.push(FlowGraphNode { + id: "query".to_string(), + kind: FlowGraphNodeKind::Query, + title: query.clone(), + subtitle: "Semantic search lens".to_string(), + weight: 72.0, + collection_id: None, + collection_name: None, + capture_id: None, + url: None, + host: None, + captured_at: None, + excerpt: None, + score: None, + }); + } + + for collection in &library.collections { + nodes.push(FlowGraphNode { + id: flow_hub_node_id(&collection.id), + kind: FlowGraphNodeKind::Hub, + title: collection.name.clone(), + subtitle: format!( + "{} sources · {} chunks", + collection.capture_count, collection.chunk_count + ), + weight: (42.0 + (collection.capture_count as f64 * 7.0)).min(92.0), + collection_id: Some(collection.id.clone()), + collection_name: Some(collection.name.clone()), + capture_id: None, + url: None, + host: None, + captured_at: Some(collection.updated_at.clone()), + excerpt: Some(collection.description.clone()) + .filter(|description| !description.is_empty()), + score: None, + }); + } + + for source in &sources { + let node_id = flow_source_node_id(&source.capture.id); + nodes.push(FlowGraphNode { + id: node_id.clone(), + kind: FlowGraphNodeKind::Source, + title: source.capture.title.clone(), + subtitle: source.collection_name.clone(), + weight: (24.0 + (source.capture.chunk_count as f64 * 2.0)).min(58.0), + collection_id: Some(source.capture.collection_id.clone()), + collection_name: Some(source.collection_name.clone()), + capture_id: Some(source.capture.id.clone()), + url: Some(source.capture.url.clone()), + host: Some(get_tab_host(&source.capture.url)), + captured_at: Some(source.capture.captured_at.clone()), + excerpt: Some(source.excerpt.clone()).filter(|excerpt| !excerpt.is_empty()), + score: query_scores.get(&node_id).copied().map(round_score), + }); + } + + let mut edges = Vec::::new(); + for source in &sources { + push_flow_graph_edge( + &mut edges, + &flow_hub_node_id(&source.capture.collection_id), + &flow_source_node_id(&source.capture.id), + FlowGraphEdgeKind::Contains, + 36.0, + ); + } + + if !query_scores.is_empty() { + let mut matches = query_scores.iter().collect::>(); + matches.sort_by(|left, right| right.1.partial_cmp(left.1).unwrap_or(Ordering::Equal)); + for (node_id, score) in matches.into_iter().take(FLOW_GRAPH_QUERY_MATCH_LIMIT) { + if *score >= FLOW_GRAPH_MIN_EDGE_SCORE { + push_flow_graph_edge( + &mut edges, + "query", + node_id, + FlowGraphEdgeKind::QueryMatch, + *score, + ); + } + } + } + + let mut semantic_edges = Vec::::new(); + for left_index in 0..sources.len() { + for right_index in (left_index + 1)..sources.len() { + let left = &sources[left_index]; + let right = &sources[right_index]; + let distance = cosine_distance(&left.vector, &right.vector); + let weight = semantic_score_from_distance(distance); + if weight >= FLOW_GRAPH_MIN_EDGE_SCORE { + semantic_edges.push(FlowEdgeCandidate { + from: flow_source_node_id(&left.capture.id), + to: flow_source_node_id(&right.capture.id), + weight, + }); + } + } + } + semantic_edges.sort_by(|left, right| { + right + .weight + .partial_cmp(&left.weight) + .unwrap_or(Ordering::Equal) + }); + let mut neighbor_counts = HashMap::::new(); + let mut semantic_edge_count = 0_usize; + for candidate in semantic_edges { + if semantic_edge_count >= FLOW_GRAPH_MAX_SEMANTIC_EDGES { + break; + } + let left_count = *neighbor_counts.get(&candidate.from).unwrap_or(&0); + let right_count = *neighbor_counts.get(&candidate.to).unwrap_or(&0); + if left_count >= FLOW_GRAPH_NEIGHBORS_PER_SOURCE + || right_count >= FLOW_GRAPH_NEIGHBORS_PER_SOURCE + { + continue; + } + push_flow_graph_edge( + &mut edges, + &candidate.from, + &candidate.to, + FlowGraphEdgeKind::Semantic, + candidate.weight, + ); + semantic_edge_count += 1; + *neighbor_counts.entry(candidate.from).or_insert(0) += 1; + *neighbor_counts.entry(candidate.to).or_insert(0) += 1; + } + + Ok(FlowGraphResult { + query, + generated_at: now(), + nodes, + edges, + hub_count: library.collections.len(), + source_count: sources.len(), + omitted_source_count: indexed_source_count.saturating_sub(sources.len()), + }) +} + +pub(crate) fn average_flow_source_vector(chunks: &[ChunkRecord]) -> Option> { + let first = chunks.first()?; + let dimensions = first.vector.len(); + if dimensions == 0 { + return None; + } + let mut total = vec![0.0_f32; dimensions]; + let mut count = 0.0_f32; + for chunk in chunks { + if chunk.vector.len() != dimensions { + continue; + } + for (index, value) in chunk.vector.iter().enumerate() { + total[index] += *value; + } + count += 1.0; + } + if count == 0.0 { + return None; + } + for value in &mut total { + *value /= count; + } + Some(normalize_embedding(&total)) +} + +pub(crate) fn flow_hub_node_id(collection_id: &str) -> String { + format!("hub-{collection_id}") +} + +pub(crate) fn flow_source_node_id(capture_id: &str) -> String { + format!("source-{capture_id}") +} + +pub(crate) fn push_flow_graph_edge( + edges: &mut Vec, + from: &str, + to: &str, + kind: FlowGraphEdgeKind, + weight: f64, +) { + if from == to { + return; + } + edges.push(FlowGraphEdge { + id: format!("{}-{from}-{to}", flow_edge_kind_label(kind)), + from: from.to_string(), + to: to.to_string(), + kind, + weight: round_score(weight), + }); +} + +pub(crate) fn flow_edge_kind_label(kind: FlowGraphEdgeKind) -> &'static str { + match kind { + FlowGraphEdgeKind::Contains => "contains", + FlowGraphEdgeKind::Semantic => "semantic", + FlowGraphEdgeKind::QueryMatch => "query", + } +} diff --git a/src-tauri/src/iceberg.rs b/src-tauri/src/iceberg.rs new file mode 100644 index 0000000..a120307 --- /dev/null +++ b/src-tauri/src/iceberg.rs @@ -0,0 +1,513 @@ +//! iCE (Iceberg Complexity Explorer): prompt construction and the normaliser that +//! turns a model's loosely-shaped JSON into laid-out, level-banded topics. +//! +//! The normaliser is defensive on purpose — a 4B model will return missing fields, +//! out-of-range scores, and duplicate names, and none of that should reach the UI. + +use super::*; + +pub(crate) fn build_iceberg_prompt(keyword: &str) -> String { + format!( + r#"Create an iceberg chart for the topic "{keyword}". + +Return JSON only with this exact shape: +{{ + "recommendedItemCount": 24, + "items": [ + {{ + "name": "Visible phrase", + "description": "One short explanation.", + "depthScore": 12, + "familiarity": 85, + "specificity": 20, + "jargonDensity": 15, + "prerequisiteDepth": 10, + "obscurity": 12, + "reason": "Common public entry point." + }} + ] +}} + +Rules: +- Choose recommendedItemCount based on topic scope: 12-18 for narrow topics, 20-30 for medium topics, 32-45 for broad domains. +- Return between 12 and 45 usable items. +- Cover all five iceberg layers whenever the topic has enough material. Include at least one item intentionally scored for each depth band: 0-19, 20-39, 40-59, 60-79, and 80-100. +- Prefer 2-5 genuinely obscure or specialist items for the 80-100 band instead of stopping at intermediate concepts. +- Do not include more than 10 items that would clearly belong to the same depth band. +- Use concise item names that fit on a node. +- Every item must include a non-empty description. +- Scores are integers from 0-100, never 1-10. +- depthScore: intended iceberg depth, where 0-19 is public surface knowledge and 80-100 is obscure, specialist, prerequisite-heavy, or rarely explained. +- familiarity: how likely a general audience already knows this. +- specificity: how narrow the concept is within the topic. +- jargonDensity: how much specialist vocabulary is needed. +- prerequisiteDepth: how much prior conceptual knowledge is required. +- obscurity: how rarely this appears in mainstream explainers or beginner material. +- reason: one short explanation for why the item sits at its depth. +- Do not include level; the app will compute levels from the scoring rubric. +- Do not include markdown, prose, or comments."# + ) +} + +#[derive(Clone)] +pub(crate) struct ScoredIcebergItem { + pub(crate) item: IcebergItem, + pub(crate) score: f64, +} + +pub(crate) fn normalize_iceberg_items(response: &str) -> Cmd> { + let json_text = response + .trim() + .trim_start_matches("```json") + .trim_start_matches("```") + .trim_end_matches("```") + .trim(); + let parsed = match serde_json::from_str::(json_text) { + Ok(value) => value, + Err(_) => { + let start = json_text + .find('[') + .ok_or_else(|| "Local model did not return valid iceberg JSON.".to_string())?; + let end = json_text + .rfind(']') + .ok_or_else(|| "Local model did not return valid iceberg JSON.".to_string())?; + serde_json::from_str(&json_text[start..=end]) + .map_err(|_| "Local model did not return valid iceberg JSON.".to_string())? + } + }; + let requested_count = iceberg_requested_count(&parsed); + let items_value = parsed.get("items").cloned().unwrap_or(parsed); + let raw_items = items_value + .as_array() + .ok_or_else(|| "Local model did not return valid iceberg JSON.".to_string())?; + let target_count = requested_count + .unwrap_or(raw_items.len()) + .clamp(ICEBERG_MIN_ITEMS, ICEBERG_MAX_ITEMS); + let mut candidates = Vec::::new(); + let mut seen_names = HashSet::::new(); + for raw in raw_items { + let name = raw + .get("name") + .and_then(|value| value.as_str()) + .unwrap_or("") + .trim(); + let description = raw + .get("description") + .and_then(|value| value.as_str()) + .unwrap_or("") + .trim(); + if name.is_empty() || description.is_empty() { + continue; + } + + let dedupe_key = slugify(name); + if dedupe_key.is_empty() || !seen_names.insert(dedupe_key) { + continue; + } + + let fallback_level = iceberg_level_field(raw).unwrap_or(3); + let fallback_score = iceberg_fallback_score(fallback_level); + let familiarity = iceberg_metric_field(raw, &["familiarity"]); + let specificity = iceberg_metric_field(raw, &["specificity"]); + let jargon_density = iceberg_metric_field(raw, &["jargonDensity", "jargon_density"]); + let prerequisite_depth = + iceberg_metric_field(raw, &["prerequisiteDepth", "prerequisite_depth"]); + let obscurity = iceberg_metric_field(raw, &["obscurity"]); + let explicit_depth = + iceberg_metric_field(raw, &["depthScore", "depth_score", "complexityScore"]); + let score = explicit_depth.unwrap_or_else(|| { + iceberg_depth_score( + familiarity, + specificity, + jargon_density, + prerequisite_depth, + obscurity, + fallback_score, + ) + }); + let score = round_score(score.clamp(0.0, 100.0)); + let level = iceberg_level_from_score(score); + let reason = iceberg_string_field(raw, &["reason", "rationale", "levelReason"]) + .map(|reason| reason.chars().take(220).collect::()); + + candidates.push(ScoredIcebergItem { + item: IcebergItem { + id: String::new(), + name: name.to_string(), + description: description.to_string(), + level, + x: 0.0, + y: 0.0, + depth_score: Some(score), + familiarity, + specificity, + jargon_density, + prerequisite_depth, + obscurity, + reason, + }, + score, + }); + } + + stretch_iceberg_candidate_levels(&mut candidates); + + candidates.sort_by(|left, right| { + left.item + .level + .cmp(&right.item.level) + .then_with(|| { + left.score + .partial_cmp(&right.score) + .unwrap_or(Ordering::Equal) + }) + .then_with(|| left.item.name.cmp(&right.item.name)) + }); + + let mut buckets = HashMap::>::new(); + for candidate in candidates { + buckets + .entry(candidate.item.level) + .or_default() + .push(candidate); + } + + let mut selected = Vec::::new(); + let mut by_level = HashMap::::new(); + + for level in 1..=ICEBERG_LEVEL_COUNT { + if selected.len() >= target_count { + break; + } + if let Some(candidate) = take_iceberg_candidate(&mut buckets, level) { + *by_level.entry(level).or_default() += 1; + selected.push(candidate); + } + } + + while selected.len() < target_count { + let mut added_any = false; + for level in 1..=ICEBERG_LEVEL_COUNT { + if selected.len() >= target_count { + break; + } + if by_level.get(&level).copied().unwrap_or_default() >= ICEBERG_MAX_ITEMS_PER_LEVEL { + continue; + } + if let Some(candidate) = take_iceberg_candidate(&mut buckets, level) { + *by_level.entry(level).or_default() += 1; + selected.push(candidate); + added_any = true; + } + } + + if !added_any { + break; + } + } + + selected.sort_by(|left, right| { + left.item + .level + .cmp(&right.item.level) + .then_with(|| { + left.score + .partial_cmp(&right.score) + .unwrap_or(Ordering::Equal) + }) + .then_with(|| left.item.name.cmp(&right.item.name)) + }); + + let mut normalized = Vec::::new(); + let mut ids = Vec::::new(); + let mut layout_counts = HashMap::::new(); + for candidate in selected { + let level_count = layout_counts.entry(candidate.item.level).or_default(); + let index = *level_count; + *level_count += 1; + let mut item = candidate.item; + item.id = unique_slug(&format!("{}-{}-{}", item.level, index + 1, item.name), &ids); + ids.push(item.id.clone()); + item.x = ICEBERG_LEVEL_LANES[index % ICEBERG_LEVEL_LANES.len()]; + item.y = 120.0 + index as f64 * 44.0; + normalized.push(item); + } + + if normalized.is_empty() { + Err("Local model did not return any usable iceberg items.".to_string()) + } else { + Ok(normalized) + } +} + +pub(crate) fn stretch_iceberg_candidate_levels(candidates: &mut [ScoredIcebergItem]) { + if candidates.len() < ICEBERG_LEVEL_COUNT as usize { + return; + } + + let present_levels = candidates + .iter() + .map(|candidate| candidate.item.level) + .collect::>(); + if present_levels.len() >= ICEBERG_LEVEL_COUNT as usize { + return; + } + + // Small local models often compress everything into middle scores. For iCE, + // the useful ranking is relative to the requested topic, so spread a usable + // set across all five bands instead of returning empty deep layers. + let mut indices = (0..candidates.len()).collect::>(); + indices.sort_by(|left, right| { + candidates[*left] + .score + .partial_cmp(&candidates[*right].score) + .unwrap_or(Ordering::Equal) + .then_with(|| { + candidates[*left] + .item + .name + .cmp(&candidates[*right].item.name) + }) + }); + let total = candidates.len(); + for (rank, index) in indices.into_iter().enumerate() { + let level = ((rank * ICEBERG_LEVEL_COUNT as usize) / total + 1) + .min(ICEBERG_LEVEL_COUNT as usize) as u8; + let score = iceberg_score_for_level_band(candidates[index].score, level); + candidates[index].score = score; + candidates[index].item.level = level; + candidates[index].item.depth_score = Some(score); + } +} + +pub(crate) fn iceberg_score_for_level_band(score: f64, level: u8) -> f64 { + let level = level.clamp(1, ICEBERG_LEVEL_COUNT); + let lower = f64::from(level.saturating_sub(1)) * 20.0; + let upper = if level == ICEBERG_LEVEL_COUNT { + 100.0 + } else { + f64::from(level) * 20.0 - 1.0 + }; + round_score(score.clamp(lower, upper)) +} + +pub(crate) fn take_iceberg_candidate( + buckets: &mut HashMap>, + level: u8, +) -> Option { + let bucket = buckets.get_mut(&level)?; + if bucket.is_empty() { + None + } else { + Some(bucket.remove(0)) + } +} + +pub(crate) fn iceberg_requested_count(value: &serde_json::Value) -> Option { + let count = value + .get("recommendedItemCount") + .or_else(|| value.get("itemCount")) + .or_else(|| value.get("recommended_count")) + .and_then(|value| value.as_u64())?; + usize::try_from(count).ok() +} + +pub(crate) fn iceberg_level_field(value: &serde_json::Value) -> Option { + let level = value + .get("level") + .or_else(|| value.get("proposedLevel")) + .or_else(|| value.get("depthLevel")) + .and_then(|value| value.as_u64())?; + Some((level as u8).clamp(1, ICEBERG_LEVEL_COUNT)) +} + +pub(crate) fn iceberg_metric_field(value: &serde_json::Value, names: &[&str]) -> Option { + names + .iter() + .find_map(|name| value.get(*name).and_then(json_number)) + .map(normalize_iceberg_metric) +} + +pub(crate) fn iceberg_string_field(value: &serde_json::Value, names: &[&str]) -> Option { + names + .iter() + .find_map(|name| value.get(*name).and_then(|value| value.as_str())) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + +pub(crate) fn json_number(value: &serde_json::Value) -> Option { + value + .as_f64() + .or_else(|| { + value + .as_str() + .and_then(|text| text.trim().parse::().ok()) + }) + .filter(|value| value.is_finite()) +} + +pub(crate) fn normalize_iceberg_metric(value: f64) -> f64 { + let normalized = if (0.0..=1.0).contains(&value) { + value * 100.0 + } else { + value + }; + round_score(normalized.clamp(0.0, 100.0)) +} + +pub(crate) fn iceberg_fallback_score(level: u8) -> f64 { + match level.clamp(1, ICEBERG_LEVEL_COUNT) { + 1 => 10.0, + 2 => 30.0, + 3 => 50.0, + 4 => 70.0, + _ => 90.0, + } +} + +pub(crate) fn iceberg_depth_score( + familiarity: Option, + specificity: Option, + jargon_density: Option, + prerequisite_depth: Option, + obscurity: Option, + fallback_score: f64, +) -> f64 { + let familiarity = familiarity.unwrap_or(100.0 - fallback_score); + let specificity = specificity.unwrap_or(fallback_score); + let jargon_density = jargon_density.unwrap_or(fallback_score); + let prerequisite_depth = prerequisite_depth.unwrap_or(fallback_score); + let obscurity = obscurity.unwrap_or(fallback_score); + + specificity * 0.3 + + jargon_density * 0.25 + + prerequisite_depth * 0.2 + + obscurity * 0.15 + + (100.0 - familiarity) * 0.1 +} + +pub(crate) fn iceberg_level_from_score(score: f64) -> u8 { + match score { + value if value < 20.0 => 1, + value if value < 40.0 => 2, + value if value < 60.0 => 3, + value if value < 80.0 => 4, + _ => 5, + } +} + +pub(crate) fn clamp_optional_score(value: Option, min: f64, max: f64) -> Option { + value + .filter(|value| value.is_finite()) + .map(|value| round_score(value.clamp(min, max))) +} + +pub(crate) fn normalize_saved_items(items: Vec) -> Vec { + items + .into_iter() + .filter(|item| !item.name.trim().is_empty() && !item.description.trim().is_empty()) + .map(|mut item| { + item.name = item.name.trim().to_string(); + item.description = item.description.trim().to_string(); + item.level = item.level.clamp(1, ICEBERG_LEVEL_COUNT); + item.depth_score = clamp_optional_score(item.depth_score, 0.0, 100.0); + item.familiarity = clamp_optional_score(item.familiarity, 0.0, 100.0); + item.specificity = clamp_optional_score(item.specificity, 0.0, 100.0); + item.jargon_density = clamp_optional_score(item.jargon_density, 0.0, 100.0); + item.prerequisite_depth = clamp_optional_score(item.prerequisite_depth, 0.0, 100.0); + item.obscurity = clamp_optional_score(item.obscurity, 0.0, 100.0); + item.reason = item + .reason + .map(|reason| reason.trim().chars().take(220).collect::()) + .filter(|reason| !reason.is_empty()); + if item.id.trim().is_empty() { + item.id = unique_slug(&format!("{}-{}", item.level, item.name), &[]); + } + item + }) + .collect() +} + +pub(crate) fn saved_iceberg_summary(iceberg: &SavedIceberg) -> SavedIcebergSummary { + SavedIcebergSummary { + id: iceberg.id.clone(), + title: iceberg.title.clone(), + keyword: iceberg.iceberg.keyword.clone(), + model: iceberg.iceberg.model.clone(), + icon: iceberg.icon.clone(), + generated_at: iceberg.iceberg.generated_at.clone(), + saved_at: iceberg.saved_at.clone(), + updated_at: iceberg.updated_at.clone(), + item_count: iceberg.iceberg.items.len(), + } +} + +pub(crate) fn dedupe_citations(citations: Vec) -> Vec { + let mut unique = Vec::::new(); + let mut indexes = HashMap::::new(); + for citation in citations { + let key = normalize_citation_key(&citation.url); + if let Some(existing_index) = indexes.get(&key).copied() { + let existing = &mut unique[existing_index]; + if !existing.text.contains(&citation.text) { + // Join non-contiguous excerpts from the same page with a neutral + // marker. A numbered "Chunk N" label here leaks into the model's + // context and gets echoed as an uncitable "[Chunk N]" reference. + existing.text = format!("{}\n\n[…]\n\n{}", existing.text, citation.text) + .chars() + .take(9000) + .collect(); + } + existing.score = existing.score.min(citation.score); + } else { + indexes.insert(key, unique.len()); + unique.push(citation); + } + } + unique +} + +pub(crate) fn split_text(text: &str, chunk_size: usize, overlap: usize) -> Vec { + let chars = text.chars().collect::>(); + let mut chunks = Vec::new(); + let mut start = 0; + while start < chars.len() { + let end = (start + chunk_size).min(chars.len()); + let chunk = chars[start..end] + .iter() + .collect::() + .trim() + .to_string(); + if !chunk.is_empty() { + chunks.push(chunk); + } + if end == chars.len() { + break; + } + start = end.saturating_sub(overlap); + } + chunks +} + +pub(crate) fn cosine_distance(left: &[f32], right: &[f32]) -> f64 { + if left.is_empty() || left.len() != right.len() { + return f64::INFINITY; + } + let mut dot = 0.0_f64; + let mut left_norm = 0.0_f64; + let mut right_norm = 0.0_f64; + for (left, right) in left.iter().zip(right.iter()) { + let left = *left as f64; + let right = *right as f64; + dot += left * right; + left_norm += left * left; + right_norm += right * right; + } + if left_norm == 0.0 || right_norm == 0.0 { + f64::INFINITY + } else { + 1.0 - dot / (left_norm.sqrt() * right_norm.sqrt()) + } +} diff --git a/src-tauri/src/inference.rs b/src-tauri/src/inference.rs new file mode 100644 index 0000000..60dca25 --- /dev/null +++ b/src-tauri/src/inference.rs @@ -0,0 +1,681 @@ +//! The llama.cpp calls themselves: embeddings, chat completion, and iCE generation, +//! plus the extractive fallback used when only the embedding model is installed. + +use super::*; + +// The chat path streams tokens and status lines back to the renderer through boxed +// callbacks. Named here so the generate/complete signatures stay readable. +pub(crate) type TokenSink = Box; +pub(crate) type StatusSink = Box; + +/// Where a generation reports back to. Both sinks come from the same +/// ChatStreamEmitter and are always supplied together, so they travel together. +#[derive(Default)] +pub(crate) struct ChatSinks { + pub(crate) token: Option, + pub(crate) status: Option, +} + +pub(crate) async fn local_embed( + state: &State<'_, Backend>, + settings: &UserSettings, + inputs: Vec, +) -> Cmd>> { + local_embed_with_progress(state, settings, inputs, None).await +} + +pub(crate) async fn local_embed_query( + state: &State<'_, Backend>, + settings: &UserSettings, + query: String, +) -> Cmd> { + local_embed( + state, + settings, + vec![embedding_query_input(&state.paths, settings, &query)], + ) + .await? + .into_iter() + .next() + .ok_or_else(|| "Local embedding model returned no embedding.".to_string()) +} + +pub(crate) fn embedding_query_input( + paths: &DataPaths, + settings: &UserSettings, + query: &str, +) -> String { + let catalog = model_catalog(paths, &settings.local_model); + let Some(model_path) = catalog.embedding_model.as_deref() else { + return query.to_string(); + }; + let label = model_label(model_path).to_lowercase(); + + if label.contains("qwen3-embedding") { + return format!("Instruct: {QWEN3_EMBEDDING_RETRIEVAL_INSTRUCTION}\nQuery: {query}"); + } + + query.to_string() +} + +pub(crate) async fn local_embed_with_progress( + state: &State<'_, Backend>, + settings: &UserSettings, + inputs: Vec, + progress: Option, +) -> Cmd>> { + let catalog = model_catalog(&state.paths, &settings.local_model); + let model_path = catalog.embedding_model.ok_or_else(|| { + format!( + "No local embedding model found. Install AiON MiST/Qwen3 Embedding, add another embedding GGUF to {}, or set {AETHER_EMBEDDING_MODEL_ENV}.", + state.paths.models_path.display() + ) + })?; + let runtime = Arc::clone(&state.native_runtime); + task::spawn_blocking(move || { + let mut runtime = runtime + .lock() + .map_err(|_| "Local model runtime is unavailable.".to_string())?; + match progress { + Some(progress) => runtime.embed_with_progress(&model_path, inputs, Some(progress)), + None => runtime.embed(&model_path, inputs), + } + }) + .await + .map_err(|error| error.to_string())? +} + +pub(crate) async fn local_chat( + state: &State<'_, Backend>, + settings: &UserSettings, + prompt: &str, + citations: Vec, + // Prior turns for follow-up questions. Empty for one-shot callers such as AiR. + history: &[ConversationTurn], + stream: Option, +) -> Cmd { + let started_at = Instant::now(); + let catalog = model_catalog(&state.paths, &settings.local_model); + // With only the embedding model installed there is nothing to generate with, but + // retrieval still works. Returning the ranked passages is far more useful than an + // error, and it is what makes MiST-only a usable install rather than a dead end. + let Some(model_path) = catalog.chat_model else { + if citations.is_empty() { + return Err(format!( + "No local chat model is installed, and no captured passages matched. Capture a page or install a chat model in {}.", + state.paths.models_path.display() + )); + } + if let Some(stream) = &stream { + stream.citations(&citations); + } + return Ok(extractive_answer( + citations, + started_at.elapsed().as_secs_f64(), + )); + }; + if let Some(stream) = &stream { + stream.citations(&citations); + } + let messages = build_chat_messages(prompt, &citations, history); + let runtime = Arc::clone(&state.native_runtime); + let cancel = Arc::clone(&state.generation_cancelled); + let model_label = model_label(&model_path); + let completion = task::spawn_blocking(move || { + let mut runtime = runtime + .lock() + .map_err(|_| "Local model runtime is unavailable.".to_string())?; + let token_stream = stream.clone(); + let token: Option = token_stream + .map(|stream| Box::new(move |delta: &str| stream.delta(delta)) as TokenSink); + let status: Option = stream + .map(|stream| Box::new(move |status: String| stream.status(&status)) as StatusSink); + runtime.complete_chat( + &model_path, + messages, + DEFAULT_GENERATION_TOKENS, + 0.2, + &cancel, + ChatSinks { token, status }, + ) + }) + .await + .map_err(|error| error.to_string())??; + let elapsed_seconds = started_at.elapsed().as_secs_f64(); + let answer = normalize_answer_citations(&clean_model_output(&completion.text), citations.len()); + let tokens_per_second = if completion.generated_tokens > 0 && elapsed_seconds > 0.0 { + completion.generated_tokens as f64 / elapsed_seconds + } else { + 0.0 + }; + let chunks = citations.len(); + Ok(ChatResult { + answer, + model: model_label, + citations, + metrics: ChatMetrics { + generated_tokens: completion.generated_tokens, + tokens_per_second, + elapsed_seconds, + chunks, + }, + }) +} + +// The retrieval-only answer. Deliberately presented as quoted passages with their +// sources rather than as prose: nothing here was generated, and dressing excerpts up +// as an answer would imply reasoning that did not happen. +pub(crate) const EXTRACTIVE_MODEL_LABEL: &str = "AiON MiST (passages only)"; + +pub(crate) fn extractive_answer(citations: Vec, elapsed_seconds: f64) -> ChatResult { + let mut answer = String::from( + "No chat model is installed, so ÆTHER cannot write an answer. These are the passages from your library that best match the question:\n\n", + ); + for (index, citation) in citations.iter().enumerate() { + answer.push_str(&format!( + "**{}. {}** [{}]\n\n> {}\n\n", + index + 1, + citation.title.trim(), + index + 1, + semantic_trail_excerpt(citation.text.trim(), 480).replace('\n', " ") + )); + } + answer.push_str( + "_Install a chat model in Settings to get written answers grounded in these sources._", + ); + + let chunks = citations.len(); + ChatResult { + answer, + model: EXTRACTIVE_MODEL_LABEL.to_string(), + citations, + metrics: ChatMetrics { + // Nothing was generated, so the token metrics are honestly zero. + generated_tokens: 0, + tokens_per_second: 0.0, + elapsed_seconds, + chunks, + }, + } +} + +pub(crate) async fn local_generate_iceberg( + state: &State<'_, Backend>, + settings: &UserSettings, + topic: &str, +) -> Cmd { + let catalog = model_catalog(&state.paths, &settings.local_model); + let model_path = catalog.chat_model.ok_or_else(|| { + format!( + "No local generative GGUF model found. Add Gemma or another chat model to {} or set {AETHER_CHAT_MODEL_ENV}.", + state.paths.models_path.display() + ) + })?; + let messages = vec![ChatPromptMessage { + role: "user", + content: build_iceberg_prompt(topic), + }]; + let runtime = Arc::clone(&state.native_runtime); + let cancel = Arc::clone(&state.generation_cancelled); + let model_label = model_label(&model_path); + let completion = task::spawn_blocking(move || { + let mut runtime = runtime + .lock() + .map_err(|_| "Local model runtime is unavailable.".to_string())?; + runtime.complete_chat( + &model_path, + messages, + DEFAULT_ICEBERG_GENERATION_TOKENS, + 0.35, + &cancel, + ChatSinks::default(), + ) + }) + .await + .map_err(|error| error.to_string())??; + let generated = completion.text; + if state.generation_cancelled.load(AtomicOrdering::Relaxed) { + return Err("Crystallization stopped.".to_string()); + } + let generated = clean_model_output(&generated); + Ok(IcebergResult { + keyword: topic.to_string(), + model: model_label, + items: normalize_iceberg_items(&generated)?, + generated_at: now(), + }) +} + +impl NativeModelRuntime { + pub(crate) fn ensure_backend(&mut self) -> Cmd<()> { + if self.backend.is_some() { + return Ok(()); + } + + let mut backend = LlamaBackend::init().map_err(|error| error.to_string())?; + backend.void_logs(); + self.backend = Some(backend); + Ok(()) + } + + pub(crate) fn ensure_model(&mut self, kind: NativeModelKind, path: &Path) -> Cmd<()> { + let path = canonical_model_path(path); + let current_path = match kind { + NativeModelKind::Chat => self.chat.as_ref().map(|loaded| loaded.path.as_path()), + NativeModelKind::Embedding => { + self.embedding.as_ref().map(|loaded| loaded.path.as_path()) + } + }; + if current_path == Some(path.as_path()) { + return Ok(()); + } + + self.ensure_backend()?; + let backend = self + .backend + .as_ref() + .ok_or_else(|| "Local model backend is not initialized.".to_string())?; + // Mobile: load weights into anonymous memory instead of mmapping the + // GGUF. Mmapped weight pages are ordinary page cache, and Android + // evicts them under the memory pressure the native tab WebViews + // create — after which every generated token faults back to flash and + // decode slows to a crawl. Malloc'd weights are app RSS and stay put. + let use_mmap = if cfg!(mobile) { + false + } else { + backend.supports_mmap() + }; + let mut params = LlamaModelParams::default().with_use_mmap(use_mmap); + let use_gpu = match kind { + NativeModelKind::Chat => local_gpu_enabled(), + NativeModelKind::Embedding => embedding_gpu_enabled(), + }; + if use_gpu && backend.supports_gpu_offload() { + params = params.with_n_gpu_layers(999); + } else { + params = params + .with_n_gpu_layers(0) + .with_devices(&[]) + .map_err(|error| format!("Failed to select CPU model backend: {error}"))?; + } + let model = LlamaModel::load_from_file(backend, &path, ¶ms).map_err(|error| { + format!("Failed to load local model {}: {error}", model_label(&path)) + })?; + let loaded = LoadedNativeModel { path, model }; + match kind { + NativeModelKind::Chat => self.chat = Some(loaded), + NativeModelKind::Embedding => self.embedding = Some(loaded), + } + Ok(()) + } + + pub(crate) fn embed(&mut self, model_path: &Path, inputs: Vec) -> Cmd>> { + self.embed_with_progress(model_path, inputs, None) + } + + pub(crate) fn embed_with_progress( + &mut self, + model_path: &Path, + inputs: Vec, + progress: Option, + ) -> Cmd>> { + if inputs.is_empty() { + return Ok(Vec::new()); + } + + self.ensure_model(NativeModelKind::Embedding, model_path)?; + let backend = self + .backend + .as_ref() + .ok_or_else(|| "Local model backend is not initialized.".to_string())?; + let model = &self + .embedding + .as_ref() + .ok_or_else(|| "Local embedding model is not loaded.".to_string())? + .model; + let threads = auto_thread_count(); + let total = inputs.len(); + let mut embeddings = Vec::with_capacity(total); + let mut tokenized_inputs = Vec::with_capacity(total); + + if let Some(progress) = &progress { + progress.emit_message("Tokenizing chunks", 0, total); + } + + for input in inputs { + let tokens = model + .str_to_token(&input, AddBos::Always) + .map_err(|error| error.to_string())?; + if tokens.is_empty() { + return Err("Local embedding input produced no tokens.".to_string()); + } + tokenized_inputs.push(tokens); + } + + let max_sequences = if is_qwen3_embedding_model(model_path) { + 1 + } else { + embedding_batch_size().min(16) + }; + let max_batch_tokens = embedding_batch_token_limit(); + let mut input_index = 0; + let mut batches = Vec::new(); + + while input_index < tokenized_inputs.len() { + let mut batch_token_count = 0usize; + let mut batch_end = input_index; + + while batch_end < tokenized_inputs.len() + && batch_end - input_index < max_sequences + && (batch_token_count == 0 + || batch_token_count + tokenized_inputs[batch_end].len() <= max_batch_tokens) + { + batch_token_count += tokenized_inputs[batch_end].len(); + batch_end += 1; + } + + batches.push((input_index, batch_end, batch_token_count)); + input_index = batch_end; + } + + let max_batch_token_count = batches + .iter() + .map(|(_, _, batch_token_count)| *batch_token_count) + .max() + .unwrap_or_default(); + let max_batch_sequence_count = batches + .iter() + .map(|(batch_start, batch_end, _)| batch_end - batch_start) + .max() + .unwrap_or(1); + let n_ctx = embedding_context_tokens(max_batch_token_count); + if max_batch_token_count as u32 > n_ctx { + return Err(format!( + "Local embedding batch is too long for the embedding context: {} tokens exceeds {}.", + max_batch_token_count, n_ctx + )); + } + let n_batch = n_ctx.max(max_batch_token_count as u32).max(512); + let offload_embedding_ops = embedding_gpu_enabled(); + let ctx_params = LlamaContextParams::default() + .with_n_ctx(NonZeroU32::new(n_ctx)) + .with_n_seq_max(max_batch_sequence_count as u32) + .with_n_batch(n_batch) + .with_n_ubatch(n_batch) + .with_n_threads(threads) + .with_n_threads_batch(threads) + .with_embeddings(true) + .with_offload_kqv(offload_embedding_ops) + .with_op_offload(offload_embedding_ops) + .with_attention_type(embedding_attention_type(model_path)) + .with_pooling_type(embedding_pooling_type(model_path)); + let mut ctx = model + .new_context(backend, ctx_params) + .map_err(|error| error.to_string())?; + + for (batch_start, batch_end, batch_token_count) in batches { + let batch_sequence_count = batch_end - batch_start; + if let Some(progress) = &progress { + progress.emit_message( + format!( + "Embedding chunks {}-{batch_end} of {total}", + batch_start + 1 + ), + batch_start, + total, + ); + } + + ctx.clear_kv_cache(); + let mut batch = LlamaBatch::new(batch_token_count, batch_sequence_count as i32); + for (sequence_index, tokens) in + tokenized_inputs[batch_start..batch_end].iter().enumerate() + { + batch + .add_sequence(tokens, sequence_index as i32, false) + .map_err(|error| error.to_string())?; + } + // Qwen3 Embedding is decoder-style; the llama_encode path segfaults in + // llm_build_qwen3 on macOS with llama-cpp-2 0.1.146. + if qwen3_embedding_decode(model_path) { + ctx.decode(&mut batch).map_err(|error| error.to_string())?; + } else { + ctx.encode(&mut batch).map_err(|error| error.to_string())?; + } + + for sequence_index in 0..batch_sequence_count { + let embedding = ctx + .embeddings_seq_ith(sequence_index as i32) + .map_err(|error| error.to_string())?; + embeddings.push(normalize_embedding(embedding)); + } + if let Some(progress) = &progress { + progress.emit(batch_end, total); + } + } + + Ok(embeddings) + } + + #[cfg(desktop)] + pub(crate) fn warm_embedding_model(&mut self, model_path: &Path) -> Cmd<()> { + self.ensure_model(NativeModelKind::Embedding, model_path) + } + + pub(crate) fn complete_chat( + &mut self, + model_path: &Path, + messages: Vec, + max_tokens: usize, + temperature: f32, + cancel: &AtomicBool, + mut sinks: ChatSinks, + ) -> Cmd { + // The first ask pays for the multi-GB model load; on phone-class + // storage that is long enough to read as a hang without a status. + let needs_load = self + .chat + .as_ref() + .map(|loaded| loaded.path != canonical_model_path(model_path)) + .unwrap_or(true); + if needs_load { + if let Some(callback) = sinks.status.as_mut() { + callback(format!( + "Loading {} (first ask takes a moment)", + friendly_model_label(model_path) + )); + } + } + self.ensure_model(NativeModelKind::Chat, model_path)?; + let rendered = { + let model = &self + .chat + .as_ref() + .ok_or_else(|| "Local chat model is not loaded.".to_string())? + .model; + render_model_chat_prompt(model, &messages)? + }; + self.complete_loaded_prompt( + &rendered.prompt, + max_tokens, + temperature, + rendered.add_bos, + cancel, + sinks, + ) + } + + #[allow(clippy::too_many_arguments)] + pub(crate) fn complete_loaded_prompt( + &mut self, + prompt: &str, + max_tokens: usize, + temperature: f32, + add_bos: AddBos, + cancel: &AtomicBool, + mut sinks: ChatSinks, + ) -> Cmd { + let backend = self + .backend + .as_ref() + .ok_or_else(|| "Local model backend is not initialized.".to_string())?; + let model = &self + .chat + .as_ref() + .ok_or_else(|| "Local chat model is not loaded.".to_string())? + .model; + let mut tokens = model + .str_to_token(prompt, add_bos) + .map_err(|error| error.to_string())?; + if tokens.is_empty() { + return Err("Local chat prompt produced no tokens.".to_string()); + } + + let n_ctx = chat_context_tokens(); + let max_prompt_tokens = + n_ctx.saturating_sub((max_tokens as u32).min(1024)).max(512) as usize; + if tokens.len() > max_prompt_tokens { + tokens = tokens[tokens.len() - max_prompt_tokens..].to_vec(); + } + let n_batch = (chat_batch_token_limit() as u32).min(n_ctx).max(512); + // Mobile: small micro-batches keep the compute buffer allocation + // phone-sized and make prefill progress tick in visible steps. + let n_ubatch = if cfg!(mobile) { + n_batch.min(512) + } else { + n_batch.min(2048) + } + .max(512); + let threads = auto_thread_count(); + let offload_ops = local_gpu_enabled(); + let ctx_params = LlamaContextParams::default() + .with_n_ctx(NonZeroU32::new(n_ctx)) + .with_n_batch(n_batch) + .with_n_ubatch(n_ubatch) + .with_n_threads(threads) + .with_n_threads_batch(threads) + .with_offload_kqv(offload_ops) + .with_op_offload(offload_ops); + let mut ctx = model + .new_context(backend, ctx_params) + .map_err(|error| error.to_string())?; + + let last_prompt_index = tokens.len().saturating_sub(1); + let prompt_batch_limit = if cfg!(mobile) { + (n_batch as usize).min(512) + } else { + n_batch as usize + }; + let total_prompt_tokens = tokens.len(); + let mut prompt_cursor = 0usize; + let mut sample_index = 0; + while prompt_cursor < tokens.len() { + if cancel.load(AtomicOrdering::Relaxed) { + return Err("Generation stopped.".to_string()); + } + if let Some(callback) = sinks.status.as_mut() { + let percent = prompt_cursor * 100 / total_prompt_tokens; + callback(format!("Reading context {percent}%")); + } + let prompt_end = (prompt_cursor + prompt_batch_limit).min(tokens.len()); + let mut prompt_batch = LlamaBatch::new(prompt_end - prompt_cursor, 1); + for (offset, token) in tokens[prompt_cursor..prompt_end].iter().enumerate() { + let index = prompt_cursor + offset; + prompt_batch + .add(*token, index as i32, &[0], index == last_prompt_index) + .map_err(|error| error.to_string())?; + } + ctx.decode(&mut prompt_batch) + .map_err(|error| error.to_string())?; + if prompt_end == tokens.len() { + sample_index = prompt_batch.n_tokens() - 1; + } + prompt_cursor = prompt_end; + } + if let Some(callback) = sinks.status.as_mut() { + callback("Generating answer".to_string()); + } + + let mut sampler = LlamaSampler::chain_simple([ + LlamaSampler::top_k(DEFAULT_TOP_K), + LlamaSampler::top_p(DEFAULT_TOP_P, 1), + LlamaSampler::temp(temperature), + LlamaSampler::dist(0xA371_2026), + ]); + let mut decoder = UTF_8.new_decoder(); + let mut output = String::new(); + let mut generated_tokens = 0usize; + let mut streamed_len = 0usize; + let mut batch = LlamaBatch::new(1, 1); + // Not a loop counter: this is the absolute position in the KV cache, which + // starts after the prompt and is what llama_batch entries are keyed by. + let mut position = tokens.len() as i32; + + // clippy reads `position` as a loop counter and suggests enumerate(). It is + // not one: it is the absolute KV-cache position that llama_batch entries are + // keyed by, it starts after the prompt, and it is not incremented on every + // iteration. + #[allow(clippy::explicit_counter_loop)] + for _ in 0..max_tokens { + if cancel.load(AtomicOrdering::Relaxed) { + break; + } + let token = sampler.sample(&ctx, sample_index); + if model.is_eog_token(token) { + break; + } + generated_tokens = generated_tokens.saturating_add(1); + let piece = model + .token_to_piece(token, &mut decoder, true, None) + .map_err(|error| error.to_string())?; + output.push_str(&piece); + if contains_stop_marker(&output) { + break; + } + if let Some(on_token) = sinks.token.as_mut() { + let safe_end = stream_safe_len(&output); + if safe_end > streamed_len { + on_token(&output[streamed_len..safe_end]); + streamed_len = safe_end; + } + } + + batch.clear(); + batch + .add(token, position, &[0], true) + .map_err(|error| error.to_string())?; + ctx.decode(&mut batch).map_err(|error| error.to_string())?; + sample_index = batch.n_tokens() - 1; + position += 1; + } + + if output.trim().is_empty() && cancel.load(AtomicOrdering::Relaxed) { + return Err("Generation stopped.".to_string()); + } + + Ok(ChatCompletion { + text: output, + generated_tokens, + }) + } +} + +#[derive(Clone)] +pub(crate) struct ModelDownloadSpec { + pub(crate) id: &'static str, + pub(crate) label: &'static str, + pub(crate) repository: &'static str, + pub(crate) revision: &'static str, + pub(crate) filename: &'static str, + pub(crate) destination: PathBuf, + pub(crate) expected_bytes: u64, +} + +impl ModelDownloadSpec { + pub(crate) fn source_url(&self) -> String { + format!( + "https://huggingface.co/{}/resolve/{}/{}?download=true", + self.repository, self.revision, self.filename + ) + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6afd8cb..8650bd0 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,23 @@ +mod air; +mod chat; +mod commands; +mod diagnostics; +mod extract; +mod flow; +mod iceberg; +mod inference; +mod model_catalog; +mod model_downloads; +mod retrieval; +mod retrieval_scoring; +mod store; +mod system; +mod trail; +mod types; +mod util; +mod vectors; +mod webview; + use chrono::{DateTime, Utc}; use encoding_rs::UTF_8; use llama_cpp_2::{ @@ -35,6 +55,27 @@ use tokio::io::AsyncWriteExt; use tokio::task; use url::Url; +use air::*; +use chat::*; +use commands::*; +use diagnostics::{diag_error, diag_info, diag_warn}; + +use extract::*; +use flow::*; +use iceberg::*; +use inference::*; +use model_catalog::*; +use model_downloads::*; +use retrieval::*; +use retrieval_scoring::*; +use store::*; +use system::*; +use trail::*; +use types::*; +use util::*; +use vectors::*; +use webview::*; + const CHUNKS_TABLE: &str = "chunks"; // Every on-disk store is written temp-then-rename, keeping the previous good copy // beside it. See write_bytes_atomically / read_json_or_default. @@ -88,6 +129,9 @@ const AETHER_FIND_RESULT_EVENT: &str = "aether:find-result"; const AETHER_CHAT_STREAM_EVENT: &str = "aether:chat-stream"; const AETHER_DOWNLOAD_EVENT: &str = "aether:download"; const AETHER_MODEL_DOWNLOAD_PROGRESS_EVENT: &str = "aether:model-download-progress"; +// Only the desktop updater emits progress; mobile updates through the store. +#[cfg(desktop)] +const AETHER_UPDATE_PROGRESS_EVENT: &str = "aether:update-progress"; const AETHER_MODEL_DIR_ENV: &str = "AETHER_MODEL_DIR"; const AETHER_CHAT_MODEL_ENV: &str = "AETHER_CHAT_MODEL"; const AETHER_EMBEDDING_MODEL_ENV: &str = "AETHER_EMBEDDING_MODEL"; @@ -214,9972 +258,738 @@ const ICEBERG_MAX_ITEMS_PER_LEVEL: usize = 10; type Cmd = Result; -struct Backend { - paths: DataPaths, - tabs: Mutex, - #[cfg(desktop)] - webviews: Mutex, - // Where the renderer wants live web content placed, in CSS pixels, reported via - // aether_layout_set_web_content_bounds. Both shells use it; on desktop it takes - // precedence over the SIDEBAR_WIDTH/BROWSER_VIEW_TOP/PANEL_WIDTH constants. - web_content_bounds: Mutex, - client: Client, - native_runtime: Arc>, - vectors: tokio::sync::RwLock>, - generation_cancelled: Arc, - // Throttle for window geometry writes; resize/move fire continuously. - #[cfg(desktop)] - window_geometry_saved_at: Mutex>, - // Destination chosen at request time, keyed by URL. macOS omits the path in the - // Finished event, so without this the completion toast has nothing to reveal. - #[cfg(desktop)] - pending_downloads: Mutex>, -} - -#[cfg(desktop)] -#[derive(Default)] -struct NativeBrowserViews { - views: HashMap, -} - -// Where live web content belongs inside the window, in CSS px, as measured by the -// renderer. Both shells report the same rect: Android positions native WebViews with -// it, desktop positions its child webviews with it. Measuring beats hardcoding, -// because the chrome that defines these edges is owned by CSS. -#[derive(Clone, Copy, Default, PartialEq)] -struct WebContentBounds { - top: f64, - left: f64, - width: f64, - height: f64, -} - -#[derive(Default)] -struct NativeModelRuntime { - backend: Option, - chat: Option, - embedding: Option, -} - -struct LoadedNativeModel { - path: PathBuf, - model: LlamaModel, -} - -#[derive(Clone)] -struct EmbeddingProgress { - app: AppHandle, - message: String, -} - -impl EmbeddingProgress { - fn emit(&self, current: usize, total: usize) { - emit_capture_progress(&self.app, &self.message, Some(current), Some(total)); - } - - fn emit_message(&self, message: impl Into, current: usize, total: usize) { - emit_capture_progress(&self.app, message, Some(current), Some(total)); - } -} - -struct ChatPromptMessage { - role: &'static str, - content: String, -} - -struct RenderedChatPrompt { - prompt: String, - add_bos: AddBos, -} - -#[derive(Clone, Copy)] -enum NativeModelKind { - Chat, - Embedding, -} - -enum WebviewHistoryDirection { - Back, - Forward, -} - -struct ModelCatalog { - models: Vec, - chat_model: Option, - embedding_model: Option, - error: Option, -} - -#[derive(Clone)] -struct DataPaths { - db_path: PathBuf, - library_path: PathBuf, - settings_path: PathBuf, - icebergs_path: PathBuf, - conversations_path: PathBuf, - session_path: PathBuf, - air_exports_path: PathBuf, - chunks_path: PathBuf, - models_path: PathBuf, - // User-owned library snapshots (see aether_system_export_library). - exports_path: PathBuf, -} - -#[derive(Clone)] -struct TabState { - tabs: Vec, - active_app_id: String, - active_tab_id: String, - dashboard_open: bool, - modal_overlay_open: bool, - panel_collapsed: bool, -} - -#[derive(Clone)] -struct ManagedTab { - id: String, - app_id: String, - title: String, - url: String, - is_loading: bool, - favicon: Option, - theme_color: Option, - history: Vec, - history_index: usize, - // On Android the tab's real history lives in its native WebView, whose - // canGoBack/canGoForward are reported via aether_tabs_report_native_event. - // They extend (OR with) the Rust-side history, which still tracks entries - // the WebView never saw — most notably the aether://start page. - native_can_go_back: Option, - native_can_go_forward: Option, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct AppSummary { - id: String, - name: String, - category: String, - home_url: String, - current_url: String, - title: String, - is_active: bool, - is_loading: bool, - can_go_back: bool, - can_go_forward: bool, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct BrowserTabSummary { - id: String, - app_id: String, - title: String, - url: String, - host: String, - is_active: bool, - is_loading: bool, - can_go_back: bool, - can_go_forward: bool, - #[serde(skip_serializing_if = "Option::is_none")] - favicon: Option, - #[serde(skip_serializing_if = "Option::is_none")] - theme_color: Option, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct AetherState { - apps: Vec, - tabs: Vec, - active_app_id: String, - active_tab_id: String, - dashboard_open: bool, - panel_collapsed: bool, -} - -#[derive(Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct HubShortcutSummary { - id: String, - title: String, - url: String, - host: String, - created_at: String, - #[serde(skip_serializing_if = "Option::is_none")] - favicon: Option, - #[serde(skip_serializing_if = "Option::is_none")] - theme_color: Option, -} - -#[derive(Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct BrowserSettings { - default_search_engine: String, -} - -#[derive(Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct UpdateSettings { - #[serde(default = "default_update_auto_check")] - auto_check: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - last_checked_at: Option, -} - -#[derive(Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct AppSettings { - browser: BrowserSettings, - developer_mode: bool, - updates: UpdateSettings, -} - -#[derive(Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct CollectionSummary { - id: String, - name: String, - description: String, - #[serde(skip_serializing_if = "Option::is_none")] - icon: Option, - created_at: String, - updated_at: String, - capture_count: usize, - chunk_count: usize, -} - -#[derive(Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct CaptureMetadata { - #[serde(skip_serializing_if = "Option::is_none")] - note: Option, - #[serde(skip_serializing_if = "Option::is_none")] - summary: Option, - #[serde(skip_serializing_if = "Option::is_none")] - tags: Option>, -} - -#[derive(Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct CaptureSummary { - id: String, - collection_id: String, - title: String, - url: String, - app_id: String, - captured_at: String, - chunk_count: usize, - #[serde(skip_serializing_if = "Option::is_none")] - metadata: Option, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct CaptureResult { - #[serde(flatten)] - capture: CaptureSummary, - collection_name: String, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct CaptureProgress { - message: String, - #[serde(skip_serializing_if = "Option::is_none")] - current: Option, - #[serde(skip_serializing_if = "Option::is_none")] - total: Option, +fn vector_data_path(json_path: &Path) -> PathBuf { + json_path.with_extension("vec") } -#[derive(Clone, Serialize, Deserialize)] +// v1 stored vectors inline as JSON numbers. Kept only to migrate existing installs. +#[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct SearchResult { +struct LegacyChunkRecord { id: String, + vector: Vec, + text: String, collection_id: String, capture_id: String, - app_id: String, title: String, url: String, + app_id: String, captured_at: String, chunk_index: usize, - text: String, - score: f64, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct SemanticTrailRoot { - title: String, - url: String, - host: String, - excerpt: String, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] -#[serde(rename_all = "kebab-case")] -enum SemanticTrailReason { - SemanticMatch, - RecentCapture, - SameCollection, -} - -#[derive(Clone, Copy, Serialize)] -#[serde(rename_all = "kebab-case")] -enum SemanticTrailEdgeKind { - SemanticMatch, - SameHost, - SameCollection, } -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct SemanticTrailScoreBreakdown { - total: f64, - semantic: f64, - recency: f64, +#[derive(Deserialize)] +struct LegacyVectorStoreData { + #[serde(default)] + chunks: Vec, } -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct SemanticTrailItem { - id: String, - collection_id: String, - collection_name: String, - capture_id: String, - app_id: String, +struct CapturedPage { title: String, url: String, - host: String, - captured_at: String, - chunk_index: usize, - excerpt: String, - score: SemanticTrailScoreBreakdown, - reasons: Vec, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct SemanticTrailEdge { - from: String, - to: String, - kind: SemanticTrailEdgeKind, - weight: f64, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct CaptureHubSuggestion { - collection_id: String, - collection_name: String, - confidence: f64, - sample_title: String, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct SemanticTrailResult { - query: String, - generated_at: String, - root: SemanticTrailRoot, - items: Vec, - edges: Vec, -} - -#[derive(Clone, Copy, Serialize)] -#[serde(rename_all = "kebab-case")] -enum FlowGraphNodeKind { - Query, - Hub, - Source, -} - -#[derive(Clone, Copy, Serialize)] -#[serde(rename_all = "kebab-case")] -enum FlowGraphEdgeKind { - Contains, - Semantic, - QueryMatch, + text: String, } -#[derive(Clone, Serialize)] +#[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct FlowGraphNode { - id: String, - kind: FlowGraphNodeKind, - title: String, - subtitle: String, - weight: f64, - #[serde(skip_serializing_if = "Option::is_none")] - collection_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - collection_name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - capture_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] +struct BrowserPageSnapshot { + html: Option, url: Option, - #[serde(skip_serializing_if = "Option::is_none")] - host: Option, - #[serde(skip_serializing_if = "Option::is_none")] - captured_at: Option, - #[serde(skip_serializing_if = "Option::is_none")] - excerpt: Option, - #[serde(skip_serializing_if = "Option::is_none")] - score: Option, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct FlowGraphEdge { - id: String, - from: String, - to: String, - kind: FlowGraphEdgeKind, - weight: f64, + title: Option, + description: Option, + body_text: Option, } -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct FlowGraphResult { - query: String, - generated_at: String, - nodes: Vec, - edges: Vec, - hub_count: usize, - source_count: usize, - omitted_source_count: usize, +impl Backend { + fn new(app_data_dir: PathBuf) -> Self { + let db_path = app_data_dir.join("aether-realms"); + let models_path = default_models_path(&app_data_dir); + Self { + paths: DataPaths { + chunks_path: db_path.join(format!("{CHUNKS_TABLE}.json")), + db_path, + library_path: app_data_dir.join("aether-library").join("library.json"), + settings_path: app_data_dir.join("aether-settings").join("settings.json"), + icebergs_path: app_data_dir.join("aether-icebergs").join("icebergs.json"), + conversations_path: app_data_dir + .join("aether-conversations") + .join("conversations.json"), + session_path: app_data_dir.join("aether-session").join("session.json"), + air_exports_path: app_data_dir.join("aether-air"), + exports_path: app_data_dir.join(LIBRARY_EXPORT_DIR), + models_path, + }, + tabs: Mutex::new(TabState::new()), + #[cfg(desktop)] + webviews: Mutex::new(NativeBrowserViews::default()), + web_content_bounds: Mutex::new(WebContentBounds::default()), + client: Client::builder() + .user_agent("Aether/1.0 Tauri") + .build() + .expect("reqwest client"), + native_runtime: Arc::new(Mutex::new(NativeModelRuntime::default())), + vectors: tokio::sync::RwLock::new(None), + generation_cancelled: Arc::new(AtomicBool::new(false)), + #[cfg(desktop)] + window_geometry_saved_at: Mutex::new(None), + #[cfg(desktop)] + pending_downloads: Mutex::new(HashMap::new()), + } + } } -#[derive(Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ChatResult { - answer: String, - model: String, - citations: Vec, - metrics: ChatMetrics, -} +impl TabState { + fn new() -> Self { + let initial = ManagedTab::new("browser", START_PAGE_URL); + let active_tab_id = initial.id.clone(); + Self { + tabs: vec![initial], + active_app_id: "browser".to_string(), + active_tab_id, + dashboard_open: true, + modal_overlay_open: false, + panel_collapsed: true, + } + } -#[derive(Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ChatMetrics { - generated_tokens: usize, - tokens_per_second: f64, - elapsed_seconds: f64, - chunks: usize, -} + fn state(&self) -> AetherState { + AetherState { + apps: self.apps(), + tabs: self.tabs(), + active_app_id: self.active_app_id.clone(), + active_tab_id: self.active_tab_id.clone(), + dashboard_open: self.dashboard_open, + panel_collapsed: self.panel_collapsed, + } + } -struct ChatCompletion { - text: String, - generated_tokens: usize, -} + fn apps(&self) -> Vec { + let active = self.active_tab(); + vec![AppSummary { + id: "browser".to_string(), + name: "Browser".to_string(), + category: "Web".to_string(), + home_url: "https://www.google.com".to_string(), + current_url: active + .map(|tab| tab.url.clone()) + .unwrap_or_else(|| "https://www.google.com".to_string()), + title: active + .map(|tab| tab.title.clone()) + .unwrap_or_else(|| "Browser".to_string()), + is_active: !self.dashboard_open, + is_loading: active.map(|tab| tab.is_loading).unwrap_or(false), + can_go_back: active.map(|tab| tab.can_go_back()).unwrap_or(false), + can_go_forward: active.map(|tab| tab.can_go_forward()).unwrap_or(false), + }] + } -#[derive(Clone, Copy, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -enum AirLensKind { - Topic, - Flow, - Hub, - Answer, - Iceberg, -} + fn tabs(&self) -> Vec { + self.tabs + .iter() + .map(|tab| tab.summary(tab.id == self.active_tab_id && !self.dashboard_open)) + .collect() + } -impl Default for AirLensKind { - fn default() -> Self { - Self::Topic + fn active_tab(&self) -> Option<&ManagedTab> { + self.tabs.iter().find(|tab| tab.id == self.active_tab_id) } -} -#[derive(Clone, Deserialize)] -#[serde(rename_all = "camelCase")] -struct AirDossierInput { - lens: String, - lens_kind: Option, - collection_id: Option, - capture_id: Option, - saved_iceberg_id: Option, - answer: Option, - limit: Option, + fn active_tab_mut(&mut self) -> Option<&mut ManagedTab> { + let active_tab_id = self.active_tab_id.clone(); + self.tabs.iter_mut().find(|tab| tab.id == active_tab_id) + } } -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct AirDossierSource { - id: String, - title: String, - excerpt: String, - #[serde(skip_serializing_if = "Option::is_none")] - collection_name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - url: Option, - #[serde(skip_serializing_if = "Option::is_none")] - host: Option, - #[serde(skip_serializing_if = "Option::is_none")] - captured_at: Option, - #[serde(skip_serializing_if = "Option::is_none")] - score: Option, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct AirPreparedDossier { - title: String, - lens: String, - lens_kind: AirLensKind, - generated_at: String, - #[serde(skip_serializing_if = "Option::is_none")] - model: Option, - output_dir: String, - markdown_preview: String, - sources: Vec, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct AirRenderResult { - path: String, - filename: String, - title: String, - source_count: usize, - rendered_at: String, -} +impl ManagedTab { + fn new(app_id: &str, raw_url: &str) -> Self { + let url = normalize_url(raw_url, "google"); + let title = if url == START_PAGE_URL { + "New tab".to_string() + } else { + title_from_url(&url) + }; + Self { + id: uuid(), + app_id: app_id.to_string(), + title, + url: url.clone(), + is_loading: false, + favicon: None, + theme_color: None, + history: vec![url], + history_index: 0, + native_can_go_back: None, + native_can_go_forward: None, + } + } -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct AirRecentFile { - path: String, - filename: String, - title: String, - lens: String, - source_count: usize, - rendered_at: String, -} + fn navigate(&mut self, raw_url: &str, search_engine: &str) { + let url = normalize_url(raw_url, search_engine); + self.url = url.clone(); + self.title = title_from_url(&url); + self.favicon = favicon_for_url(&url); + self.theme_color = None; + self.is_loading = false; + self.history.truncate(self.history_index + 1); + self.history.push(url); + self.history_index = self.history.len().saturating_sub(1); + // Unknown until the native webview reports in after the load. + self.native_can_go_back = None; + self.native_can_go_forward = None; + } -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct ChatStreamPayload { - request_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - status: Option, - #[serde(skip_serializing_if = "Option::is_none")] - delta: Option, - #[serde(skip_serializing_if = "Option::is_none")] - citations: Option>, -} + fn commit_history_url(&mut self, url: String) { + if self.history.get(self.history_index) == Some(&url) { + return; + } -#[derive(Clone)] -struct ChatStreamEmitter { - app: AppHandle, - request_id: String, -} + if let Some(existing_index) = self + .history + .iter() + .enumerate() + .rev() + .find_map(|(index, item)| (item == &url).then_some(index)) + { + self.history_index = existing_index; + return; + } -impl ChatStreamEmitter { - fn emit(&self, payload: ChatStreamPayload) { - let _ = self.app.emit(AETHER_CHAT_STREAM_EVENT, payload); + self.history.truncate(self.history_index + 1); + self.history.push(url); + self.history_index = self.history.len().saturating_sub(1); } - fn status(&self, status: &str) { - self.emit(ChatStreamPayload { - request_id: self.request_id.clone(), - status: Some(status.to_string()), - delta: None, - citations: None, - }); + fn can_go_back(&self) -> bool { + self.history_index > 0 } - fn citations(&self, citations: &[SearchResult]) { - self.emit(ChatStreamPayload { - request_id: self.request_id.clone(), - status: Some("Generating answer".to_string()), - delta: None, - citations: Some(citations.to_vec()), - }); + fn can_go_forward(&self) -> bool { + self.history_index + 1 < self.history.len() } - fn delta(&self, delta: &str) { - self.emit(ChatStreamPayload { - request_id: self.request_id.clone(), - status: None, - delta: Some(delta.to_string()), - citations: None, - }); + fn summary(&self, is_active: bool) -> BrowserTabSummary { + BrowserTabSummary { + id: self.id.clone(), + app_id: self.app_id.clone(), + title: self.title.clone(), + url: self.url.clone(), + host: get_tab_host(&self.url), + is_active, + is_loading: self.is_loading, + can_go_back: self.can_go_back() || self.native_can_go_back.unwrap_or(false), + can_go_forward: self.can_go_forward() || self.native_can_go_forward.unwrap_or(false), + favicon: self.favicon.clone(), + theme_color: self.theme_color.clone(), + } } } -#[derive(Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct IcebergItem { - id: String, - name: String, - description: String, - level: u8, - x: f64, - y: f64, - #[serde(default, skip_serializing_if = "Option::is_none")] - depth_score: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - familiarity: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - specificity: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - jargon_density: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - prerequisite_depth: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - obscurity: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - confidence: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - reason: Option, +// macOS quit (`-[NSApplication terminate:]`) calls libc `exit()`, which runs +// C++ static destructors. llama.cpp's Metal backend frees its global device +// registry there and asserts that all residency sets were released first — but +// our loaded models (which hold those Metal buffers) are never dropped, because +// the Cocoa quit path skips Rust's normal teardown. That assert aborts the +// process ("quit unexpectedly"). Terminate immediately via `_exit`, which +// bypasses the static destructors entirely; the OS reclaims Metal/host memory, +// and all app state is already persisted per-action (no exit-time flush). +#[cfg(desktop)] +fn force_exit() -> ! { + extern "C" { + fn _exit(code: i32) -> !; + } + unsafe { _exit(0) } } -#[derive(Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct IcebergResult { - keyword: String, - model: String, - items: Vec, - generated_at: String, -} +// Bridge to the Kotlin TabsPlugin (gen/android/.../TabsPlugin.kt), which hosts +// one android.webkit.WebView per browser tab above the main app webview. This +// is the Android counterpart of the desktop `Window::add_child` path: the same +// `*_native_webview` functions drive it, keeping Rust the source of truth for +// tab state. Navigation events come back through the renderer via the +// `aether_tabs_report_native_event` command. +#[cfg(target_os = "android")] +mod android_tabs { + use serde::{de::DeserializeOwned, Deserialize, Serialize}; + use std::sync::OnceLock; + use tauri::{ + plugin::{Builder, PluginHandle, TauriPlugin}, + Manager, Wry, + }; -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct SavedIcebergSummary { - id: String, - title: String, - keyword: String, - model: String, - #[serde(skip_serializing_if = "Option::is_none")] - icon: Option, - generated_at: String, - saved_at: String, - updated_at: String, - item_count: usize, -} + pub struct AndroidTabs(PluginHandle); -#[derive(Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct SavedIceberg { - #[serde(flatten)] - iceberg: IcebergResult, - id: String, - title: String, - #[serde(skip_serializing_if = "Option::is_none")] - icon: Option, - saved_at: String, - updated_at: String, -} + // The plugin handle, additionally kept in a module global so helpers that + // only receive `State` (page capture) can reach the Kotlin side + // without threading an AppHandle through every call site. + static HANDLE: OnceLock> = OnceLock::new(); -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct SystemStatus { - runtime_ready: bool, - runtime_name: String, - embedding_model: Option, - chat_model: Option, - available_models: Vec, - chat_models: Vec, - embedding_models: Vec, - model_dir: String, - db_path: String, - library_path: String, - collections: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, -} + impl AndroidTabs { + pub fn run(&self, command: &str, payload: impl Serialize) -> Result<(), String> { + self.0 + .run_mobile_plugin::<()>(command, payload) + .map_err(|error| error.to_string()) + } -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct CreateTabInput { - url: Option, -} + pub fn run_for( + &self, + command: &str, + payload: impl Serialize, + ) -> Result { + self.0 + .run_mobile_plugin::(command, payload) + .map_err(|error| error.to_string()) + } + } -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct CreateShortcutInput { - title: String, - url: String, - favicon: Option, - theme_color: Option, -} + // Run a plugin command via the global handle. Blocks until Kotlin resolves, + // so only call from async commands (tokio workers), never the main thread — + // Kotlin resolves on the Android UI thread and would deadlock against it. + pub fn run_for_global( + command: &str, + payload: impl Serialize, + ) -> Result { + HANDLE + .get() + .ok_or_else(|| "Android tabs plugin is not ready.".to_string())? + .run_mobile_plugin::(command, payload) + .map_err(|error| error.to_string()) + } -#[cfg(desktop)] -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct PageMetadataSnapshot { - theme_color: Option, - favicon: Option, -} + pub fn init() -> TauriPlugin { + Builder::new("aether-tabs") + .setup(|app, api| { + let handle = api.register_android_plugin("com.canur.aether", "TabsPlugin")?; + let _ = HANDLE.set(handle.clone()); + app.manage(AndroidTabs(handle)); + Ok(()) + }) + .build() + } -#[cfg(desktop)] -#[derive(Deserialize)] -struct FindMatchSnapshot { - current: usize, - total: usize, -} + #[derive(Deserialize)] + pub struct ThumbnailResponse { + pub image: Option, + } -// Event payload forwarded by the renderer from the Kotlin TabsPlugin -// (window.__AETHER_TAB_EVENT__): per-tab navigation, title, and find updates. -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct NativeTabEventInput { - tab_id: String, - kind: String, - #[serde(default)] - url: Option, - #[serde(default)] - title: Option, - #[serde(default)] - is_loading: Option, - #[serde(default)] - can_go_back: Option, - #[serde(default)] - can_go_forward: Option, - #[serde(default)] - current: Option, - #[serde(default)] - total: Option, -} + #[derive(Deserialize)] + pub struct SnapshotResponse { + pub payload: String, + } -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct FindResultPayload { - tab_id: String, - current: usize, - total: usize, -} + #[derive(Deserialize, Serialize, Default)] + pub struct InsetsResponse { + pub top: f64, + pub bottom: f64, + pub left: f64, + pub right: f64, + } -#[derive(Deserialize)] -struct CreateCollectionInput { - name: String, - description: Option, - icon: Option, -} + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + pub struct TabUrlPayload<'a> { + pub tab_id: &'a str, + pub url: &'a str, + } -#[derive(Deserialize)] -struct UpdateCollectionInput { - id: String, - name: Option, - description: Option, - icon: Option, -} + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + pub struct SyncPayload<'a> { + pub active_tab_id: Option<&'a str>, + pub top: f64, + pub left: f64, + pub width: f64, + pub height: f64, + } -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct CaptureCurrentPageInput { - collection_id: String, -} + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + pub struct TabPayload<'a> { + pub tab_id: &'a str, + } -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct SearchLibraryInput { - query: String, - // None searches every hub. - #[serde(default)] - collection_id: Option, - #[serde(default)] - limit: Option, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct LibrarySearchHit { - capture_id: String, - collection_id: String, - collection_name: String, - title: String, - url: String, - host: String, - captured_at: String, - excerpt: String, - // 0-100 display score, not raw cosine distance. - score: f64, - chunk_matches: usize, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct LibrarySearchResult { - query: String, - hits: Vec, - // "semantic" when an embedding model ranked the results, "literal" when it fell - // back to substring matching so the UI can say which happened. - mode: String, - searched_chunks: usize, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct CaptureUrlInput { - collection_id: String, - url: String, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct CaptureUrlsInput { - collection_id: String, - urls: Vec, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct BulkCaptureFailure { - url: String, - reason: String, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct BulkCaptureResult { - captured: Vec, - collection_name: String, - failures: Vec, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct MoveCaptureInput { - capture_id: String, - collection_id: String, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct SearchCollectionInput { - collection_id: String, - query: String, - limit: Option, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct SemanticTrailInput { - query: Option, - limit: Option, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct FlowGraphInput { - query: Option, - source_limit: Option, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct AskChatInput { - collection_id: Option, - prompt: String, - include_current_page: Option, - request_id: Option, -} - -#[derive(Deserialize)] -struct GenerateIcebergInput { - keyword: String, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct SaveIcebergInput { - title: String, - keyword: String, - model: String, - icon: Option, - generated_at: String, - items: Vec, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct UpdateSettingsInput { - browser: Option, - developer_mode: Option, - updates: Option, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct PartialBrowserSettings { - default_search_engine: Option, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct PartialUpdateSettings { - auto_check: Option, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct DownloadProgress { - // "started" | "finished" | "failed" - status: String, - filename: String, - #[serde(skip_serializing_if = "Option::is_none")] - path: Option, - url: String, -} - -// Restored on launch so quitting no longer discards every open tab. Only what is -// needed to reopen is stored: per-tab history stays in the webview. -#[derive(Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct SessionTab { - id: String, - url: String, - #[serde(default)] - title: String, -} - -#[derive(Clone, Copy, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct SessionWindow { - width: f64, - height: f64, - x: f64, - y: f64, -} - -#[derive(Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct SessionData { - version: u8, - #[serde(default)] - tabs: Vec, - #[serde(default)] - active_tab_id: String, - #[serde(default)] - window: Option, -} - -impl Default for SessionData { - fn default() -> Self { - Self { - version: 1, - tabs: Vec::new(), - active_tab_id: String::new(), - window: None, - } - } -} - -// One completed exchange. Stored per thread so a research session survives quitting -// the app, which is the whole point of keeping answers at all. -#[derive(Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ConversationTurn { - id: String, - prompt: String, - answer: String, - model: String, - asked_at: String, - citations: Vec, - metrics: ChatMetrics, -} - -#[derive(Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ConversationData { - version: u8, - // Keyed by hub id, or CURRENT_PAGE_THREAD_KEY for page-only asks. - #[serde(default)] - threads: HashMap>, -} - -impl Default for ConversationData { - fn default() -> Self { - Self { - version: 1, - threads: HashMap::new(), - } - } -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct LibraryExportResult { - path: String, - exported_at: String, - files: Vec, - capture_count: usize, - chunk_count: usize, - byte_size: u64, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct LibraryIndexStatus { - dim: usize, - embedded: usize, - pending_reembed: usize, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct LibraryReindexResult { - embedded: usize, - // Chunks the re-index could not embed. Non-zero means the model rejected them, so - // the number is worth showing rather than reporting a clean success. - still_pending: usize, - dim: usize, - reindexed_at: String, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct UpdateCheckResult { - current_version: String, - checked_at: String, - update_available: bool, - #[serde(skip_serializing_if = "Option::is_none")] - latest_version: Option, - #[serde(skip_serializing_if = "Option::is_none")] - latest_name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - release_url: Option, - #[serde(skip_serializing_if = "Option::is_none")] - release_notes: Option, - #[serde(skip_serializing_if = "Option::is_none")] - published_at: Option, - #[serde(skip_serializing_if = "Option::is_none")] - error: Option, -} - -#[derive(Deserialize)] -struct GithubRelease { - tag_name: String, - name: Option, - html_url: String, - body: Option, - published_at: Option, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct UpdateModelsInput { - embedding_model: Option, - chat_model: Option, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct DownloadModelsInput { - chat_models: Vec, - #[serde(default)] - hf_token: Option, -} - -#[derive(Clone, Serialize)] -#[serde(rename_all = "camelCase")] -struct ModelDownloadProgress { - id: String, - label: String, - filename: String, - status: String, - downloaded_bytes: u64, - #[serde(skip_serializing_if = "Option::is_none")] - total_bytes: Option, - overall_downloaded_bytes: u64, - #[serde(skip_serializing_if = "Option::is_none")] - overall_total_bytes: Option, - #[serde(skip_serializing_if = "Option::is_none")] - message: Option, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct StatusToastInput { - message: String, - tone: String, - duration_ms: Option, -} - -#[derive(Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct LibraryData { - version: u8, - collections: Vec, - captures: Vec, - shortcuts: Vec, - migrated_realm_tables: Vec, -} - -impl Default for LibraryData { - fn default() -> Self { - Self { - version: 1, - collections: Vec::new(), - captures: Vec::new(), - shortcuts: Vec::new(), - migrated_realm_tables: Vec::new(), - } - } -} - -#[derive(Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct UserSettings { - #[serde(default = "default_settings_version")] - version: u8, - #[serde(default)] - browser: BrowserSettings, - #[serde(default)] - developer_mode: bool, - #[serde(default)] - updates: UpdateSettings, - #[serde(default, alias = "ollama")] - local_model: LocalModelSettings, -} - -#[derive(Clone, Serialize, Deserialize, Default)] -#[serde(rename_all = "camelCase")] -struct LocalModelSettings { - embedding_model: Option, - chat_model: Option, -} - -impl Default for BrowserSettings { - fn default() -> Self { - Self { - default_search_engine: "google".to_string(), - } - } -} - -impl Default for UpdateSettings { - fn default() -> Self { - Self { - auto_check: default_update_auto_check(), - last_checked_at: None, - } - } -} - -impl Default for UserSettings { - fn default() -> Self { - Self { - version: default_settings_version(), - browser: BrowserSettings::default(), - developer_mode: false, - updates: UpdateSettings::default(), - local_model: LocalModelSettings::default(), - } - } -} - -fn default_settings_version() -> u8 { - 1 -} - -fn default_update_auto_check() -> bool { - true -} - -#[derive(Serialize, Deserialize)] -struct IcebergData { - version: u8, - icebergs: Vec, -} - -impl Default for IcebergData { - fn default() -> Self { - Self { - version: 1, - icebergs: Vec::new(), - } - } -} - -#[derive(Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ChunkRecord { - id: String, - // Vectors live in the binary sidecar, not in this JSON. Serializing 1024 f32s as - // decimal text cost ~12 KB per chunk and forced a full rewrite of every vector on - // every capture; `vector_slot` is the fixed-stride index into `chunks.vec`. - #[serde(skip)] - vector: Vec, - #[serde(default)] - vector_slot: u64, - // Set when the chunk's text is retained but its vector is not usable with the - // store's current width — typically because the embedding model changed. Such a - // chunk holds no sidecar slot and is invisible to semantic search until a - // re-index re-embeds it. Keeping the text is the whole point: re-embedding is - // local compute, whereas dropping the record would force a re-fetch of the page. - #[serde(default, skip_serializing_if = "is_false")] - needs_reembed: bool, - text: String, - collection_id: String, - capture_id: String, - title: String, - url: String, - app_id: String, - captured_at: String, - chunk_index: usize, -} - -fn is_false(value: &bool) -> bool { - !*value -} - -const VECTOR_STORE_VERSION: u8 = 2; -// Reclaim dead slots only once they dominate the file, so routine deletes stay O(1) -// instead of triggering a full rewrite each time. -const VECTOR_COMPACTION_MIN_SLOTS: u64 = 512; -const VECTOR_COMPACTION_DEAD_RATIO: f64 = 0.5; -// Re-index batch size. Small enough that progress moves visibly on a large library -// and peak memory stays bounded; large enough to keep the model's batching useful. -const REINDEX_BATCH_SIZE: usize = 64; - -#[derive(Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct VectorStoreData { - version: u8, - // Embedding width. Fixes the sidecar stride, so the store holds exactly one width - // at a time. A fresh store learns it from the first vector; a migrated store takes - // the width the most chunks already use; a re-index resets it to the loaded model's. - #[serde(default)] - dim: usize, - // Monotonic slot allocator. Counts every slot ever handed out, including slots - // whose chunk was later deleted, so appends always land past live data. - #[serde(default)] - next_slot: u64, - chunks: Vec, -} - -impl Default for VectorStoreData { - fn default() -> Self { - Self { - version: VECTOR_STORE_VERSION, - dim: 0, - next_slot: 0, - chunks: Vec::new(), - } - } -} - -impl VectorStoreData { - // Single place that hands out vector slots, so no caller can append a chunk whose - // slot does not match its position in the sidecar. - fn push_chunks(&mut self, records: impl IntoIterator) { - for mut record in records { - if self.dim == 0 && !record.vector.is_empty() { - self.dim = record.vector.len(); - } - // A chunk whose width does not match the store is parked, not discarded. - // Dropping it would throw away the text as well, turning a re-embeddable - // problem into one that needs the page fetched again. - if record.vector.is_empty() || record.vector.len() != self.dim { - if !record.vector.is_empty() { - eprintln!( - "aether: chunk {} has {} dims (store is {}); parked for re-indexing", - record.id, - record.vector.len(), - self.dim - ); - } - record.vector.clear(); - record.vector_slot = 0; - record.needs_reembed = true; - self.chunks.push(record); - continue; - } - record.needs_reembed = false; - record.vector_slot = self.next_slot; - self.next_slot += 1; - self.chunks.push(record); - } - } - - // Chunks holding a sidecar slot. Parked chunks are excluded, so this — not - // `chunks.len()` — is what the slot accounting must be measured against. - fn embedded_count(&self) -> u64 { - self.chunks - .iter() - .filter(|chunk| !chunk.needs_reembed) - .count() as u64 - } - - fn pending_reembed_count(&self) -> usize { - self.chunks - .iter() - .filter(|chunk| chunk.needs_reembed) - .count() - } -} - -// Vectors sit next to the metadata as `chunks.vec`. -fn vector_data_path(json_path: &Path) -> PathBuf { - json_path.with_extension("vec") -} - -// v1 stored vectors inline as JSON numbers. Kept only to migrate existing installs. -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct LegacyChunkRecord { - id: String, - vector: Vec, - text: String, - collection_id: String, - capture_id: String, - title: String, - url: String, - app_id: String, - captured_at: String, - chunk_index: usize, -} - -#[derive(Deserialize)] -struct LegacyVectorStoreData { - #[serde(default)] - chunks: Vec, -} - -struct CapturedPage { - title: String, - url: String, - text: String, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct BrowserPageSnapshot { - html: Option, - url: Option, - title: Option, - description: Option, - body_text: Option, -} - -impl Backend { - fn new(app_data_dir: PathBuf) -> Self { - let db_path = app_data_dir.join("aether-realms"); - let models_path = default_models_path(&app_data_dir); - Self { - paths: DataPaths { - chunks_path: db_path.join(format!("{CHUNKS_TABLE}.json")), - db_path, - library_path: app_data_dir.join("aether-library").join("library.json"), - settings_path: app_data_dir.join("aether-settings").join("settings.json"), - icebergs_path: app_data_dir.join("aether-icebergs").join("icebergs.json"), - conversations_path: app_data_dir - .join("aether-conversations") - .join("conversations.json"), - session_path: app_data_dir.join("aether-session").join("session.json"), - air_exports_path: app_data_dir.join("aether-air"), - exports_path: app_data_dir.join(LIBRARY_EXPORT_DIR), - models_path, - }, - tabs: Mutex::new(TabState::new()), - #[cfg(desktop)] - webviews: Mutex::new(NativeBrowserViews::default()), - web_content_bounds: Mutex::new(WebContentBounds::default()), - client: Client::builder() - .user_agent("Aether/1.0 Tauri") - .build() - .expect("reqwest client"), - native_runtime: Arc::new(Mutex::new(NativeModelRuntime::default())), - vectors: tokio::sync::RwLock::new(None), - generation_cancelled: Arc::new(AtomicBool::new(false)), - #[cfg(desktop)] - window_geometry_saved_at: Mutex::new(None), - #[cfg(desktop)] - pending_downloads: Mutex::new(HashMap::new()), - } - } -} - -impl TabState { - fn new() -> Self { - let initial = ManagedTab::new("browser", START_PAGE_URL); - let active_tab_id = initial.id.clone(); - Self { - tabs: vec![initial], - active_app_id: "browser".to_string(), - active_tab_id, - dashboard_open: true, - modal_overlay_open: false, - panel_collapsed: true, - } - } - - fn state(&self) -> AetherState { - AetherState { - apps: self.apps(), - tabs: self.tabs(), - active_app_id: self.active_app_id.clone(), - active_tab_id: self.active_tab_id.clone(), - dashboard_open: self.dashboard_open, - panel_collapsed: self.panel_collapsed, - } - } - - fn apps(&self) -> Vec { - let active = self.active_tab(); - vec![AppSummary { - id: "browser".to_string(), - name: "Browser".to_string(), - category: "Web".to_string(), - home_url: "https://www.google.com".to_string(), - current_url: active - .map(|tab| tab.url.clone()) - .unwrap_or_else(|| "https://www.google.com".to_string()), - title: active - .map(|tab| tab.title.clone()) - .unwrap_or_else(|| "Browser".to_string()), - is_active: !self.dashboard_open, - is_loading: active.map(|tab| tab.is_loading).unwrap_or(false), - can_go_back: active.map(|tab| tab.can_go_back()).unwrap_or(false), - can_go_forward: active.map(|tab| tab.can_go_forward()).unwrap_or(false), - }] - } - - fn tabs(&self) -> Vec { - self.tabs - .iter() - .map(|tab| tab.summary(tab.id == self.active_tab_id && !self.dashboard_open)) - .collect() - } - - fn active_tab(&self) -> Option<&ManagedTab> { - self.tabs.iter().find(|tab| tab.id == self.active_tab_id) - } - - fn active_tab_mut(&mut self) -> Option<&mut ManagedTab> { - let active_tab_id = self.active_tab_id.clone(); - self.tabs.iter_mut().find(|tab| tab.id == active_tab_id) - } -} - -impl ManagedTab { - fn new(app_id: &str, raw_url: &str) -> Self { - let url = normalize_url(raw_url, "google"); - let title = if url == START_PAGE_URL { - "New tab".to_string() - } else { - title_from_url(&url) - }; - Self { - id: uuid(), - app_id: app_id.to_string(), - title, - url: url.clone(), - is_loading: false, - favicon: None, - theme_color: None, - history: vec![url], - history_index: 0, - native_can_go_back: None, - native_can_go_forward: None, - } - } - - fn navigate(&mut self, raw_url: &str, search_engine: &str) { - let url = normalize_url(raw_url, search_engine); - self.url = url.clone(); - self.title = title_from_url(&url); - self.favicon = favicon_for_url(&url); - self.theme_color = None; - self.is_loading = false; - self.history.truncate(self.history_index + 1); - self.history.push(url); - self.history_index = self.history.len().saturating_sub(1); - // Unknown until the native webview reports in after the load. - self.native_can_go_back = None; - self.native_can_go_forward = None; - } - - fn commit_history_url(&mut self, url: String) { - if self.history.get(self.history_index) == Some(&url) { - return; - } - - if let Some(existing_index) = self - .history - .iter() - .enumerate() - .rev() - .find_map(|(index, item)| (item == &url).then_some(index)) - { - self.history_index = existing_index; - return; - } - - self.history.truncate(self.history_index + 1); - self.history.push(url); - self.history_index = self.history.len().saturating_sub(1); - } - - fn can_go_back(&self) -> bool { - self.history_index > 0 - } - - fn can_go_forward(&self) -> bool { - self.history_index + 1 < self.history.len() - } - - fn summary(&self, is_active: bool) -> BrowserTabSummary { - BrowserTabSummary { - id: self.id.clone(), - app_id: self.app_id.clone(), - title: self.title.clone(), - url: self.url.clone(), - host: get_tab_host(&self.url), - is_active, - is_loading: self.is_loading, - can_go_back: self.can_go_back() || self.native_can_go_back.unwrap_or(false), - can_go_forward: self.can_go_forward() || self.native_can_go_forward.unwrap_or(false), - favicon: self.favicon.clone(), - theme_color: self.theme_color.clone(), - } - } -} - -#[cfg(desktop)] -fn ensure_native_webview(app: &AppHandle, state: &State, tab_id: &str) -> Cmd<()> { - let tab = { - let tabs = lock_tabs(state)?; - tabs.tabs - .iter() - .find(|tab| tab.id == tab_id) - .cloned() - .ok_or_else(|| format!("Unknown tab: {tab_id}"))? - }; - - // A blank start-page tab has no remote page to load — just reconcile visibility so - // any previously-active webview is hidden and the renderer's start page shows. - if tab.url == START_PAGE_URL { - return sync_native_webview_visibility(app, state); - } - - let exists = state - .webviews - .lock() - .map_err(|_| "Æther webviews are unavailable.".to_string())? - .views - .contains_key(tab_id); - if !exists { - let webview = create_native_webview(app, state, &tab)?; - state - .webviews - .lock() - .map_err(|_| "Æther webviews are unavailable.".to_string())? - .views - .insert(tab.id.clone(), webview); - } - - sync_native_webview_visibility(app, state) -} - -#[cfg(not(desktop))] -fn ensure_native_webview(app: &AppHandle, state: &State, tab_id: &str) -> Cmd<()> { - #[cfg(target_os = "android")] - { - let tab = { - let tabs = lock_tabs(state)?; - tabs.tabs - .iter() - .find(|tab| tab.id == tab_id) - .cloned() - .ok_or_else(|| format!("Unknown tab: {tab_id}"))? - }; - // Like the desktop path: a start-page tab has nothing to load, only - // visibility to reconcile so the renderer's start page shows. - if tab.url != START_PAGE_URL { - app.state::().run( - "ensure", - android_tabs::TabUrlPayload { - tab_id: &tab.id, - url: &tab.url, - }, - )?; - } - return sync_native_webview_visibility(app, state); - } - #[allow(unreachable_code)] - { - let _ = (app, state, tab_id); - Ok(()) - } -} - -#[cfg(desktop)] -fn create_native_webview( - app: &AppHandle, - state: &State, - tab: &ManagedTab, -) -> Cmd { - let window = app - .get_window("main") - .ok_or_else(|| "Æther main window is not ready.".to_string())?; - let bounds = native_webview_bounds(&window, state)?; - let label = native_webview_label(&tab.id); - let tab_id_for_navigation = tab.id.clone(); - let tab_id_for_load = tab.id.clone(); - let tab_id_for_title = tab.id.clone(); - let app_for_navigation = app.clone(); - let app_for_load = app.clone(); - let app_for_title = app.clone(); - let app_for_new_window = app.clone(); - let app_for_download = app.clone(); - let url = Url::parse(&tab.url).map_err(|error| error.to_string())?; - - let builder = WebviewBuilder::new(label, WebviewUrl::External(url)) - .user_agent(DESKTOP_BROWSER_USER_AGENT) - .on_navigation(move |url| { - let state = app_for_navigation.state::(); - update_tab_navigation_state(&state, &tab_id_for_navigation, url.as_str(), true); - let _ = emit_state(&app_for_navigation, &state); - true - }) - .on_page_load(move |webview, payload| { - let state = app_for_load.state::(); - let is_loading = payload.event() == PageLoadEvent::Started; - update_tab_navigation_state( - &state, - &tab_id_for_load, - payload.url().as_str(), - is_loading, - ); - let _ = emit_state(&app_for_load, &state); - if payload.event() == PageLoadEvent::Finished { - // Records the settled URL and title, which is what a restore needs. - schedule_session_save(&app_for_load); - let _ = webview.eval(NATIVE_WEBVIEW_SCROLLBAR_SCRIPT); - read_native_webview_metadata( - &webview, - app_for_load.clone(), - tab_id_for_load.clone(), - ); - } - }) - .on_document_title_changed(move |_webview, title| { - let state = app_for_title.state::(); - update_tab_title(&state, &tab_id_for_title, &title); - let _ = emit_state(&app_for_title, &state); - }) - .on_new_window(move |url, _features| { - let state = app_for_new_window.state::(); - let _ = create_native_tab_from_url(&app_for_new_window, &state, url.as_str()); - NewWindowResponse::Deny - }) - // Without this hook the webview silently drops downloads: clicking a PDF or - // zip link did nothing at all, with no error and no file. - .on_download(move |_webview, event| match event { - DownloadEvent::Requested { url, destination } => { - match resolve_download_destination(&app_for_download, &url) { - Some(target) => { - let filename = file_name_of(&target); - app_for_download - .state::() - .pending_downloads - .lock() - .map(|mut pending| { - pending.insert(url.to_string(), target.clone()); - }) - .ok(); - *destination = target; - emit_download_event( - &app_for_download, - "started", - &filename, - None, - url.as_str(), - ); - true - } - None => { - eprintln!("aether: no writable downloads directory; refusing download"); - emit_download_event( - &app_for_download, - "failed", - &file_name_from_url(&url), - None, - url.as_str(), - ); - false - } - } - } - DownloadEvent::Finished { url, path, success } => { - // macOS never reports the path here, so fall back to the destination - // recorded at request time. - let recorded = app_for_download - .state::() - .pending_downloads - .lock() - .ok() - .and_then(|mut pending| pending.remove(&url.to_string())); - let resolved = path.or(recorded); - let filename = resolved - .as_deref() - .map(file_name_of) - .unwrap_or_else(|| file_name_from_url(&url)); - emit_download_event( - &app_for_download, - if success { "finished" } else { "failed" }, - &filename, - resolved.as_deref(), - url.as_str(), - ); - true - } - _ => true, - }); - - let webview = window - .add_child(builder, bounds.position, bounds.size) - .map_err(|error| error.to_string())?; - webview.hide().map_err(|error| error.to_string())?; - Ok(webview) -} - -#[cfg(desktop)] -fn create_native_tab_from_url(app: &AppHandle, state: &State, raw_url: &str) -> Cmd<()> { - let url = normalize_url(raw_url, "google"); - let tab = ManagedTab::new("browser", &url); - let tab_id = tab.id.clone(); - { - let mut tabs = lock_tabs(state)?; - tabs.active_tab_id = tab_id.clone(); - tabs.active_app_id = tab.app_id.clone(); - tabs.dashboard_open = false; - tabs.tabs.push(tab); - } - ensure_native_webview(app, state, &tab_id)?; - emit_state(app, state) -} - -#[cfg(desktop)] -fn navigate_native_webview( - app: &AppHandle, - state: &State, - tab_id: &str, - url: &str, -) -> Cmd<()> { - ensure_native_webview(app, state, tab_id)?; - let parsed = Url::parse(url).map_err(|error| error.to_string())?; - let webview = state - .webviews - .lock() - .map_err(|_| "Æther webviews are unavailable.".to_string())? - .views - .get(tab_id) - .cloned() - .ok_or_else(|| format!("Native webview not found for tab: {tab_id}"))?; - webview.navigate(parsed).map_err(|error| error.to_string()) -} - -#[cfg(not(desktop))] -fn navigate_native_webview( - app: &AppHandle, - state: &State, - tab_id: &str, - url: &str, -) -> Cmd<()> { - #[cfg(target_os = "android")] - { - app.state::() - .run("navigate", android_tabs::TabUrlPayload { tab_id, url })?; - return sync_native_webview_visibility(app, state); - } - #[allow(unreachable_code)] - { - let _ = (app, state, tab_id, url); - Ok(()) - } -} - -#[cfg(desktop)] -fn navigate_native_webview_history( - _app: &AppHandle, - state: &State, - tab_id: &str, - direction: WebviewHistoryDirection, -) -> Cmd<()> { - let webview = state - .webviews - .lock() - .map_err(|_| "Æther webviews are unavailable.".to_string())? - .views - .get(tab_id) - .cloned() - .ok_or_else(|| format!("Native webview not found for tab: {tab_id}"))?; - let script = match direction { - WebviewHistoryDirection::Back => "history.back();", - WebviewHistoryDirection::Forward => "history.forward();", - }; - webview.eval(script).map_err(|error| error.to_string()) -} - -#[cfg(not(desktop))] -fn navigate_native_webview_history( - app: &AppHandle, - state: &State, - tab_id: &str, - direction: WebviewHistoryDirection, -) -> Cmd<()> { - #[cfg(target_os = "android")] - { - let _ = state; - let command = match direction { - WebviewHistoryDirection::Back => "goBack", - WebviewHistoryDirection::Forward => "goForward", - }; - return app - .state::() - .run(command, android_tabs::TabPayload { tab_id }); - } - #[allow(unreachable_code)] - { - let _ = (app, state, tab_id, direction); - Ok(()) - } -} - -#[cfg(desktop)] -fn scroll_native_webview_to_text( - _app: &AppHandle, - state: &State, - tab_id: &str, - text: &str, -) -> Cmd<()> { - let source_text = text.trim(); - if source_text.is_empty() { - return Ok(()); - } - let webview = state - .webviews - .lock() - .map_err(|_| "Æther webviews are unavailable.".to_string())? - .views - .get(tab_id) - .cloned() - .ok_or_else(|| format!("Native webview not found for tab: {tab_id}"))?; - let text_json = serde_json::to_string(source_text).map_err(|error| error.to_string())?; - let script = scroll_to_text_script().replace("__AETHER_SOURCE_TEXT__", &text_json); - webview.eval(script).map_err(|error| error.to_string()) -} - -#[cfg(not(desktop))] -fn scroll_native_webview_to_text( - app: &AppHandle, - state: &State, - tab_id: &str, - text: &str, -) -> Cmd<()> { - #[cfg(target_os = "android")] - { - let _ = state; - let source_text = text.trim(); - if source_text.is_empty() { - return Ok(()); - } - let text_json = serde_json::to_string(source_text).map_err(|error| error.to_string())?; - let script = scroll_to_text_script().replace("__AETHER_SOURCE_TEXT__", &text_json); - return app - .state::() - .run("eval", android_tabs::EvalPayload { tab_id, script }); - } - #[allow(unreachable_code)] - { - let _ = (app, state, tab_id, text); - Ok(()) - } -} - -#[cfg(desktop)] -fn find_native_webview_text( - app: &AppHandle, - state: &State, - tab_id: &str, - query: Option<&str>, - action: &str, -) -> Cmd<()> { - let webview = state - .webviews - .lock() - .map_err(|_| "Æther webviews are unavailable.".to_string())? - .views - .get(tab_id) - .cloned() - .ok_or_else(|| format!("Native webview not found for tab: {tab_id}"))?; - let query_json = match query.map(str::trim).filter(|value| !value.is_empty()) { - Some(value) => serde_json::to_string(value).map_err(|error| error.to_string())?, - None => "null".to_string(), - }; - let action_json = serde_json::to_string(action).map_err(|error| error.to_string())?; - let script = find_in_page_script() - .replace("__AETHER_FIND_QUERY__", &query_json) - .replace("__AETHER_FIND_ACTION__", &action_json); - let app = app.clone(); - let tab_id = tab_id.to_string(); - webview - .eval_with_callback(script, move |payload| { - let Ok(snapshot) = parse_json_payload::(&payload) else { - return; - }; - let _ = app.emit( - AETHER_FIND_RESULT_EVENT, - FindResultPayload { - tab_id: tab_id.clone(), - current: snapshot.current, - total: snapshot.total, - }, - ); - }) - .map_err(|error| error.to_string()) -} - -#[cfg(not(desktop))] -fn find_native_webview_text( - app: &AppHandle, - state: &State, - tab_id: &str, - query: Option<&str>, - action: &str, -) -> Cmd<()> { - #[cfg(target_os = "android")] - { - let _ = state; - // Android WebView has native find support (findAllAsync/findNext); the - // match counts come back through the FindListener as a "find" event on - // aether_tabs_report_native_event. - return app.state::().run( - "find", - android_tabs::FindPayload { - tab_id, - query: query.map(str::trim).filter(|value| !value.is_empty()), - action, - }, - ); - } - #[allow(unreachable_code)] - { - let _ = (app, state, tab_id, query, action); - Ok(()) - } -} - -#[cfg(desktop)] -fn close_native_webview(_app: &AppHandle, state: &State, tab_id: &str) -> Cmd<()> { - if let Some(webview) = state - .webviews - .lock() - .map_err(|_| "Æther webviews are unavailable.".to_string())? - .views - .remove(tab_id) - { - webview.close().map_err(|error| error.to_string())?; - } - Ok(()) -} - -#[cfg(not(desktop))] -fn close_native_webview(app: &AppHandle, state: &State, tab_id: &str) -> Cmd<()> { - #[cfg(target_os = "android")] - { - let _ = state; - return app - .state::() - .run("close", android_tabs::TabPayload { tab_id }); - } - #[allow(unreachable_code)] - { - let _ = (app, state, tab_id); - Ok(()) - } -} - -#[cfg(desktop)] -fn find_in_page_script() -> &'static str { - r#" -(() => { - const action = __AETHER_FIND_ACTION__; - const rawQuery = __AETHER_FIND_QUERY__; - const HL = 'aether-find'; - const HL_CUR = 'aether-find-current'; - const STYLE_ID = 'aether-find-style'; - const MAX = 5000; - const supportsHighlight = - typeof CSS !== 'undefined' && - CSS.highlights && - typeof Highlight !== 'undefined' && - typeof Range !== 'undefined'; - const normalize = (value) => String(value ?? '').replace(/\s+/g, ' ').trim(); - const state = (window.__aetherFind = window.__aetherFind || { query: '', index: 0, total: 0 }); - - const clearHighlights = () => { - if (supportsHighlight) { - try { CSS.highlights.delete(HL); CSS.highlights.delete(HL_CUR); } catch (error) {} - } - document.querySelectorAll('mark[data-aether-find]').forEach((mark) => { - const parent = mark.parentNode; - if (!parent) return; - while (mark.firstChild) parent.insertBefore(mark.firstChild, mark); - parent.removeChild(mark); - parent.normalize(); - }); - }; - - const ensureStyle = () => { - if (document.getElementById(STYLE_ID)) return; - const style = document.createElement('style'); - style.id = STYLE_ID; - style.textContent = - '::highlight(aether-find){background-color:#bfe9f7;color:#0e364a;}' + - '::highlight(aether-find-current){background-color:#247fa7;color:#f4fbff;}' + - 'mark[data-aether-find]{background-color:#bfe9f7;color:#0e364a;border-radius:2px;padding:0;}' + - 'mark[data-aether-find="current"]{background-color:#247fa7;color:#f4fbff;}'; - (document.head || document.documentElement).appendChild(style); - }; - - if (action === 'clear') { - clearHighlights(); - state.query = ''; state.index = 0; state.total = 0; - return { current: 0, total: 0 }; - } - - const query = normalize(rawQuery); - clearHighlights(); - if (!query) { - state.query = ''; state.index = 0; state.total = 0; - return { current: 0, total: 0 }; - } - - const collectRanges = (needle) => { - const lc = needle.toLowerCase(); - const len = lc.length; - const root = document.body || document.documentElement; - if (!root || !len) return []; - const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { - acceptNode(node) { - if (!node.nodeValue) return NodeFilter.FILTER_REJECT; - const parent = node.parentElement; - if (!parent) return NodeFilter.FILTER_REJECT; - const tag = parent.tagName; - if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT' || tag === 'TEXTAREA') { - return NodeFilter.FILTER_REJECT; - } - return NodeFilter.FILTER_ACCEPT; - } - }); - const nodes = []; - let buffer = ''; - let node; - while ((node = walker.nextNode())) { - nodes.push({ node, start: buffer.length }); - buffer += node.nodeValue; - } - const haystack = buffer.toLowerCase(); - const nodeAt = (offset) => { - let lo = 0, hi = nodes.length - 1, pick = 0; - while (lo <= hi) { - const mid = (lo + hi) >> 1; - if (nodes[mid].start <= offset) { pick = mid; lo = mid + 1; } else { hi = mid - 1; } - } - return pick; - }; - const ranges = []; - let from = 0, at; - while ((at = haystack.indexOf(lc, from)) !== -1) { - const end = at + len; - const startNode = nodeAt(at); - const endNode = nodeAt(end - 1); - try { - const range = document.createRange(); - range.setStart(nodes[startNode].node, at - nodes[startNode].start); - range.setEnd(nodes[endNode].node, end - nodes[endNode].start); - ranges.push(range); - } catch (error) {} - from = end; - if (ranges.length >= MAX) break; - } - return ranges; - }; - - const ranges = collectRanges(query); - const total = ranges.length; - if (total === 0) { - state.query = query; state.index = 0; state.total = 0; - return { current: 0, total: 0 }; - } - - let index; - if ((action === 'next' || action === 'prev') && state.query === query) { - index = state.index + (action === 'next' ? 1 : -1); - } else { - index = 0; - } - index = ((index % total) + total) % total; - state.query = query; state.index = index; state.total = total; - - ensureStyle(); - if (supportsHighlight) { - try { - const all = new Highlight(); - for (const range of ranges) all.add(range); - CSS.highlights.set(HL, all); - const current = new Highlight(); - current.add(ranges[index]); - CSS.highlights.set(HL_CUR, current); - } catch (error) {} - } else { - for (let i = ranges.length - 1; i >= 0; i--) { - try { - const mark = document.createElement('mark'); - mark.setAttribute('data-aether-find', i === index ? 'current' : 'all'); - ranges[i].surroundContents(mark); - } catch (error) {} - } - } - - let scrollTarget = null; - if (supportsHighlight) { - const node = ranges[index].startContainer; - scrollTarget = node.nodeType === 1 ? node : node.parentElement; - } else { - scrollTarget = document.querySelector('mark[data-aether-find="current"]'); - } - try { - if (scrollTarget && scrollTarget.scrollIntoView) { - scrollTarget.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'smooth' }); - } - } catch (error) {} - - return { current: index + 1, total }; -})() -"# -} - -fn scroll_to_text_script() -> &'static str { - r#" -(() => { - const sourceText = __AETHER_SOURCE_TEXT__; - const normalize = (value) => String(value || '').replace(/\s+/g, ' ').trim().toLowerCase(); - const source = normalize(sourceText); - if (!source) return; - const EXACT_HL = 'aether-source-exact'; - const STYLE_ID = 'aether-source-style'; - const supportsHighlight = - typeof CSS !== 'undefined' && - CSS.highlights && - typeof Highlight !== 'undefined' && - typeof Range !== 'undefined'; - - const words = source.split(' ').filter(Boolean).slice(0, 180); - const snippets = []; - const seen = new Set(); - const addSnippet = (start, length) => { - const snippet = words.slice(start, start + length).join(' '); - if (snippet.length >= 32 && !seen.has(snippet)) { - seen.add(snippet); - snippets.push(snippet); - } - }; - - for (const length of [28, 22, 16, 12, 9, 7]) { - const step = Math.max(3, Math.floor(length / 2)); - for (let start = 0; start < words.length; start += step) { - addSnippet(start, length); - } - } - snippets.sort((left, right) => right.length - left.length); - - const ensureStyle = () => { - if (document.getElementById(STYLE_ID)) return; - const style = document.createElement('style'); - style.id = STYLE_ID; - style.textContent = - '::highlight(aether-source-exact){background-color:rgba(255,224,102,0.72);color:inherit;}' + - 'mark[data-aether-source-range]{background-color:rgba(255,224,102,0.72);color:inherit;border-radius:2px;padding:0;}'; - (document.head || document.documentElement).appendChild(style); - }; - - const clearExactHighlights = () => { - if (supportsHighlight) { - try { CSS.highlights.delete(EXACT_HL); } catch (error) {} - } - document.querySelectorAll('mark[data-aether-source-range]').forEach((mark) => { - const parent = mark.parentNode; - if (!parent) return; - while (mark.firstChild) parent.insertBefore(mark.firstChild, mark); - parent.removeChild(mark); - parent.normalize(); - }); - }; - - const restorePreviousHighlights = () => { - clearExactHighlights(); - document.querySelectorAll('[data-aether-source-highlight="true"]').forEach((element) => { - element.style.outline = element.dataset.aetherPreviousOutline || ''; - element.style.boxShadow = element.dataset.aetherPreviousBoxShadow || ''; - element.style.backgroundColor = element.dataset.aetherPreviousBackgroundColor || ''; - element.removeAttribute('data-aether-source-highlight'); - element.removeAttribute('data-aether-previous-outline'); - element.removeAttribute('data-aether-previous-box-shadow'); - element.removeAttribute('data-aether-previous-background-color'); - }); - }; - - const textNodeAccepted = (node) => { - if (!node.nodeValue) return false; - const parent = node.parentElement; - if (!parent) return false; - const tag = parent.tagName; - return tag !== 'SCRIPT' && tag !== 'STYLE' && tag !== 'NOSCRIPT' && tag !== 'TEXTAREA'; - }; - - const collectTextIndex = () => { - const root = document.body || document.documentElement; - if (!root) return null; - const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { - acceptNode(node) { - return textNodeAccepted(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; - } - }); - const map = []; - let text = ''; - let node; - while ((node = walker.nextNode())) { - const value = node.nodeValue || ''; - for (let index = 0; index < value.length; index += 1) { - const char = value[index]; - if (/\s/.test(char)) { - if (text && !text.endsWith(' ')) { - text += ' '; - map.push({ node, offset: index }); - } - } else { - text += char.toLowerCase(); - map.push({ node, offset: index }); - } - } - } - while (text.endsWith(' ')) { - text = text.slice(0, -1); - map.pop(); - } - return { text, map }; - }; - - const rangeFromIndex = (index, length, map) => { - const start = map[index]; - const end = map[index + length - 1]; - if (!start || !end) return null; - try { - const range = document.createRange(); - range.setStart(start.node, start.offset); - range.setEnd(end.node, end.offset + 1); - return range; - } catch (error) { - return null; - } - }; - - const findRangeMatch = () => { - const index = collectTextIndex(); - if (!index) return null; - for (const snippet of snippets) { - const at = index.text.indexOf(snippet); - if (at === -1) continue; - const range = rangeFromIndex(at, snippet.length, index.map); - if (range) return range; - } - return null; - }; - - const scrollRangeIntoView = (range) => { - try { - const rects = range.getClientRects(); - const rect = rects.length ? rects[0] : range.getBoundingClientRect(); - if (rect && Number.isFinite(rect.top)) { - const top = rect.top + window.scrollY - window.innerHeight * 0.42; - window.scrollTo({ top: Math.max(0, top), behavior: 'smooth' }); - return; - } - } catch (error) {} - const node = range.startContainer; - const element = node.nodeType === 1 ? node : node.parentElement; - if (element && element.scrollIntoView) { - element.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'smooth' }); - } - }; - - const highlightRange = (range) => { - restorePreviousHighlights(); - ensureStyle(); - let highlighted = false; - if (supportsHighlight) { - try { - const exact = new Highlight(); - exact.add(range); - CSS.highlights.set(EXACT_HL, exact); - highlighted = true; - } catch (error) {} - } - if (!highlighted) { - try { - const mark = document.createElement('mark'); - mark.setAttribute('data-aether-source-range', 'true'); - range.surroundContents(mark); - highlighted = true; - } catch (error) {} - } - scrollRangeIntoView(range); - window.setTimeout(clearExactHighlights, 12000); - return highlighted; - }; - - const isVisible = (element) => { - const style = window.getComputedStyle(element); - const rect = element.getBoundingClientRect(); - return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0; - }; - - const scoreElement = (element) => { - const tag = element.tagName.toLowerCase(); - if (['p', 'li', 'blockquote', 'td', 'th', 'figcaption', 'dd', 'dt'].includes(tag)) return 0; - if (['article', 'section', 'main'].includes(tag)) return 2; - return 1; - }; - - const highlight = (element) => { - restorePreviousHighlights(); - element.dataset.aetherSourceHighlight = 'true'; - element.dataset.aetherPreviousOutline = element.style.outline || ''; - element.dataset.aetherPreviousBoxShadow = element.style.boxShadow || ''; - element.dataset.aetherPreviousBackgroundColor = element.style.backgroundColor || ''; - element.style.outline = '3px solid rgba(66, 153, 225, 0.72)'; - element.style.boxShadow = '0 0 0 8px rgba(66, 153, 225, 0.16)'; - element.style.backgroundColor = 'rgba(255, 246, 189, 0.42)'; - element.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'smooth' }); - window.setTimeout(() => { - if (element.dataset.aetherSourceHighlight === 'true') restorePreviousHighlights(); - }, 12000); - }; - - const findMatch = () => { - const elements = Array.from( - document.querySelectorAll('p, li, blockquote, td, th, figcaption, dd, dt, article, section, main, div') - ) - .filter(isVisible) - .map((element) => ({ element, text: normalize(element.textContent) })) - .filter((item) => item.text.length >= 32) - .sort((left, right) => { - const tagScore = scoreElement(left.element) - scoreElement(right.element); - if (tagScore !== 0) return tagScore; - return left.text.length - right.text.length; - }); - - for (const snippet of snippets) { - const match = elements.find((item) => item.text.includes(snippet)); - if (match) return match.element; - } - - return null; - }; - - let attempts = 0; - const retry = () => { - attempts += 1; - const range = findRangeMatch(); - if (range && highlightRange(range)) return; - const match = findMatch(); - if (match) { - highlight(match); - return; - } - if (attempts < 28) window.setTimeout(retry, 250); - }; - - retry(); -})(); -"# -} - -#[cfg(desktop)] -fn resize_native_webviews(app: &AppHandle, state: &State) -> Cmd<()> { - sync_native_webview_visibility(app, state) -} - -#[cfg(desktop)] -fn sync_native_webview_visibility(app: &AppHandle, state: &State) -> Cmd<()> { - let (active_tab_id, show_active, panel_collapsed) = { - let tabs = lock_tabs(state)?; - // A tab parked on the start page must keep its (possibly still-alive) webview - // hidden so the renderer's start page overlay stays visible. - let active_is_start = tabs - .active_tab() - .map(|tab| tab.url == START_PAGE_URL) - .unwrap_or(false); - ( - tabs.active_tab_id.clone(), - !tabs.dashboard_open && !tabs.modal_overlay_open && !active_is_start, - tabs.panel_collapsed, - ) - }; - // Prefer the renderer-measured slot; fall back to the layout constants until the - // first report arrives. - let bounds = match reported_webview_bounds(state) { - Some(bounds) => bounds, - None => { - let window = app - .get_window("main") - .ok_or_else(|| "Æther main window is not ready.".to_string())?; - native_webview_bounds_for_window(&window, panel_collapsed)? - } - }; - let webviews = state - .webviews - .lock() - .map_err(|_| "Æther webviews are unavailable.".to_string())?; - - for (tab_id, webview) in &webviews.views { - if show_active && tab_id == &active_tab_id { - webview - .set_bounds(bounds) - .map_err(|error| error.to_string())?; - webview.show().map_err(|error| error.to_string())?; - } else { - webview.hide().map_err(|error| error.to_string())?; - } - } - - Ok(()) -} - -#[cfg(not(desktop))] -fn sync_native_webview_visibility(app: &AppHandle, state: &State) -> Cmd<()> { - #[cfg(target_os = "android")] - { - let (active_tab_id, show_active) = { - let tabs = lock_tabs(state)?; - // Same rules as desktop: keep webviews hidden behind the dashboard, - // modal overlays, and the renderer's start-page overlay. - let active_is_start = tabs - .active_tab() - .map(|tab| tab.url == START_PAGE_URL) - .unwrap_or(false); - ( - tabs.active_tab_id.clone(), - !tabs.dashboard_open && !tabs.modal_overlay_open && !active_is_start, - ) - }; - let bounds = *state - .web_content_bounds - .lock() - .map_err(|_| "Æther layout bounds are unavailable.".to_string())?; - return app.state::().run( - "sync", - android_tabs::SyncPayload { - active_tab_id: show_active.then_some(active_tab_id.as_str()), - top: bounds.top, - left: bounds.left, - width: bounds.width, - height: bounds.height, - }, - ); - } - #[allow(unreachable_code)] - { - let _ = (app, state); - Ok(()) - } -} - -#[cfg(desktop)] -fn native_webview_bounds(window: &Window, state: &State) -> Cmd { - if let Some(bounds) = reported_webview_bounds(state) { - return Ok(bounds); - } - let panel_collapsed = lock_tabs(state)?.panel_collapsed; - native_webview_bounds_for_window(window, panel_collapsed) -} - -#[cfg(desktop)] -fn native_webview_bounds_for_window(window: &Window, panel_collapsed: bool) -> Cmd { - let size = window - .inner_size() - .map_err(|error| error.to_string())? - .to_logical::(window.scale_factor().map_err(|error| error.to_string())?); - let right_width = if panel_collapsed { - PANEL_COLLAPSED_WIDTH - } else { - PANEL_WIDTH - }; - let width = (size.width - SIDEBAR_WIDTH - right_width).max(280.0); - let height = (size.height - BROWSER_VIEW_TOP).max(200.0); - - Ok(Rect { - position: Position::Logical(LogicalPosition::new(SIDEBAR_WIDTH, BROWSER_VIEW_TOP)), - size: Size::Logical(LogicalSize::new(width, height)), - }) -} - -// Preferred over the constants above: the renderer measures the actual content slot, -// so the chrome's real height and the panel's real width define the web view instead -// of numbers that silently drift whenever the CSS changes. The constants remain the -// fallback for the first frames, before the renderer has reported anything. -#[cfg(desktop)] -fn reported_webview_bounds(state: &State) -> Option { - let bounds = *state.web_content_bounds.lock().ok()?; - // A zero-size rect means the content slot is not laid out (dashboard open, or the - // very first frame); positioning a webview to it would collapse the view. - if bounds.width < 1.0 || bounds.height < 1.0 { - return None; - } - Some(Rect { - position: Position::Logical(LogicalPosition::new(bounds.left, bounds.top)), - size: Size::Logical(LogicalSize::new(bounds.width, bounds.height)), - }) -} - -#[cfg(desktop)] -fn native_webview_label(tab_id: &str) -> String { - format!("aether-browser-tab-{tab_id}") -} - -#[cfg(desktop)] -fn read_native_webview_metadata(webview: &Webview, app: AppHandle, tab_id: String) { - let script = r#"(() => { - const theme = document.querySelector('meta[name="theme-color"], meta[name="msapplication-TileColor"]'); - const icons = Array.from(document.querySelectorAll('link[rel]')) - .map((link) => { - const rel = link.getAttribute('rel') || ''; - if (!/\b(icon|apple-touch-icon|shortcut icon)\b/i.test(rel)) return null; - const href = link.href || ''; - const sizes = link.getAttribute('sizes') || ''; - const size = sizes - .split(/\s+/) - .map((item) => Number.parseInt(item, 10) || 0) - .reduce((largest, value) => Math.max(largest, value), 0); - return { href, rel, size }; - }) - .filter(Boolean) - .sort((left, right) => { - if (right.size !== left.size) return right.size - left.size; - return Number(/apple-touch-icon/i.test(right.rel)) - Number(/apple-touch-icon/i.test(left.rel)); - }); - return { - themeColor: theme?.getAttribute('content') || '', - favicon: icons[0]?.href || '' - }; - })()"#; - - let _ = webview.eval_with_callback(script, move |payload| { - let metadata = match parse_json_payload::(&payload) { - Ok(metadata) => metadata, - Err(_) => return, - }; - let favicon = metadata - .favicon - .map(|favicon| favicon.trim().to_string()) - .filter(|favicon| !favicon.is_empty()); - let theme_color = metadata - .theme_color - .as_deref() - .and_then(normalize_theme_color); - let state = app.state::(); - if update_tab_metadata(&state, &tab_id, theme_color, favicon) { - let _ = emit_state(&app, &state); - } - }); -} - -fn update_tab_navigation_state(state: &State, tab_id: &str, url: &str, is_loading: bool) { - if let Ok(mut tabs) = lock_tabs(state) { - if let Some(tab) = tabs.tabs.iter_mut().find(|tab| tab.id == tab_id) { - tab.is_loading = is_loading; - let url = url.trim(); - if !should_accept_webview_url(&tab.url, url) { - return; - } - - let url = url.to_string(); - let url_changed = tab.url != url; - tab.url = url.clone(); - tab.favicon = favicon_for_url(&url); - if url_changed { - tab.theme_color = None; - } - if tab.title == "New tab" || tab.title.is_empty() || tab.title == get_tab_host(&tab.url) - { - tab.title = title_from_url(&url); - } - if !is_loading { - tab.commit_history_url(url); - } - } - } -} - -fn should_accept_webview_url(current_url: &str, next_url: &str) -> bool { - if next_url.is_empty() { - return false; - } - // While a tab is parked on the start page, ignore stray events from its hidden - // webview so they don't overwrite the start-page sentinel. - if current_url == START_PAGE_URL { - return false; - } - if is_transient_webview_url(next_url) && !is_transient_webview_url(current_url) { - return false; - } - true -} - -fn is_transient_webview_url(url: &str) -> bool { - let normalized = url.trim().to_ascii_lowercase(); - normalized == "about:blank" - || normalized.starts_with("about:blank#") - || normalized == "about:srcdoc" -} - -#[cfg(desktop)] -fn update_tab_metadata( - state: &State, - tab_id: &str, - theme_color: Option, - favicon: Option, -) -> bool { - if let Ok(mut tabs) = lock_tabs(state) { - if let Some(tab) = tabs.tabs.iter_mut().find(|tab| tab.id == tab_id) { - let favicon = favicon.or_else(|| tab.favicon.clone()); - if tab.theme_color == theme_color && tab.favicon == favicon { - return false; - } - tab.theme_color = theme_color; - tab.favicon = favicon; - return true; - } - } - false -} - -fn update_tab_title(state: &State, tab_id: &str, title: &str) { - let title = title.trim(); - if title.is_empty() { - return; - } - if let Ok(mut tabs) = lock_tabs(state) { - if let Some(tab) = tabs.tabs.iter_mut().find(|tab| tab.id == tab_id) { - tab.title = title.to_string(); - } - } -} - -// macOS quit (`-[NSApplication terminate:]`) calls libc `exit()`, which runs -// C++ static destructors. llama.cpp's Metal backend frees its global device -// registry there and asserts that all residency sets were released first — but -// our loaded models (which hold those Metal buffers) are never dropped, because -// the Cocoa quit path skips Rust's normal teardown. That assert aborts the -// process ("Æther quit unexpectedly"). Terminate immediately via `_exit`, which -// bypasses the static destructors entirely; the OS reclaims Metal/host memory, -// and all app state is already persisted per-action (no exit-time flush). -#[cfg(desktop)] -fn force_exit() -> ! { - extern "C" { - fn _exit(code: i32) -> !; - } - unsafe { _exit(0) } -} - -// Bridge to the Kotlin TabsPlugin (gen/android/.../TabsPlugin.kt), which hosts -// one android.webkit.WebView per browser tab above the main app webview. This -// is the Android counterpart of the desktop `Window::add_child` path: the same -// `*_native_webview` functions drive it, keeping Rust the source of truth for -// tab state. Navigation events come back through the renderer via the -// `aether_tabs_report_native_event` command. -#[cfg(target_os = "android")] -mod android_tabs { - use serde::{de::DeserializeOwned, Deserialize, Serialize}; - use std::sync::OnceLock; - use tauri::{ - plugin::{Builder, PluginHandle, TauriPlugin}, - Manager, Wry, - }; - - pub struct AndroidTabs(PluginHandle); - - // The plugin handle, additionally kept in a module global so helpers that - // only receive `State` (page capture) can reach the Kotlin side - // without threading an AppHandle through every call site. - static HANDLE: OnceLock> = OnceLock::new(); - - impl AndroidTabs { - pub fn run(&self, command: &str, payload: impl Serialize) -> Result<(), String> { - self.0 - .run_mobile_plugin::<()>(command, payload) - .map_err(|error| error.to_string()) - } - - pub fn run_for( - &self, - command: &str, - payload: impl Serialize, - ) -> Result { - self.0 - .run_mobile_plugin::(command, payload) - .map_err(|error| error.to_string()) - } - } - - // Run a plugin command via the global handle. Blocks until Kotlin resolves, - // so only call from async commands (tokio workers), never the main thread — - // Kotlin resolves on the Android UI thread and would deadlock against it. - pub fn run_for_global( - command: &str, - payload: impl Serialize, - ) -> Result { - HANDLE - .get() - .ok_or_else(|| "Android tabs plugin is not ready.".to_string())? - .run_mobile_plugin::(command, payload) - .map_err(|error| error.to_string()) - } - - pub fn init() -> TauriPlugin { - Builder::new("aether-tabs") - .setup(|app, api| { - let handle = api.register_android_plugin("com.canur.aether", "TabsPlugin")?; - let _ = HANDLE.set(handle.clone()); - app.manage(AndroidTabs(handle)); - Ok(()) - }) - .build() - } - - #[derive(Deserialize)] - pub struct ThumbnailResponse { - pub image: Option, - } - - #[derive(Deserialize)] - pub struct SnapshotResponse { - pub payload: String, - } - - #[derive(Deserialize, Serialize, Default)] - pub struct InsetsResponse { - pub top: f64, - pub bottom: f64, - pub left: f64, - pub right: f64, - } - - #[derive(Serialize)] - #[serde(rename_all = "camelCase")] - pub struct TabUrlPayload<'a> { - pub tab_id: &'a str, - pub url: &'a str, - } - - #[derive(Serialize)] - #[serde(rename_all = "camelCase")] - pub struct SyncPayload<'a> { - pub active_tab_id: Option<&'a str>, - pub top: f64, - pub left: f64, - pub width: f64, - pub height: f64, - } - - #[derive(Serialize)] - #[serde(rename_all = "camelCase")] - pub struct TabPayload<'a> { - pub tab_id: &'a str, - } - - #[derive(Serialize)] - #[serde(rename_all = "camelCase")] - pub struct EvalPayload<'a> { - pub tab_id: &'a str, - pub script: String, - } - - #[derive(Serialize)] - #[serde(rename_all = "camelCase")] - pub struct FindPayload<'a> { - pub tab_id: &'a str, - pub query: Option<&'a str>, - pub action: &'a str, - } -} - -#[cfg_attr(mobile, tauri::mobile_entry_point)] -pub fn run() { - let builder = tauri::Builder::default(); - #[cfg(desktop)] - let builder = builder - .menu(|app| { - let menu = Menu::new(app)?; - let focus_address_item = MenuItem::with_id( - app, - AETHER_FOCUS_ADDRESS_MENU_ID, - "Focus Address Bar", - true, - Some("CmdOrCtrl+L"), - )?; - let new_tab_item = MenuItem::with_id( - app, - AETHER_NEW_TAB_MENU_ID, - "New Tab", - true, - Some("CmdOrCtrl+T"), - )?; - let find_item = MenuItem::with_id( - app, - AETHER_FIND_MENU_ID, - "Find in Page", - true, - Some("CmdOrCtrl+F"), - )?; - let open_dashboard_item = MenuItem::with_id( - app, - AETHER_OPEN_DASHBOARD_MENU_ID, - "Open Dashboard", - true, - Some("CmdOrCtrl+1"), - )?; - let open_ice_item = MenuItem::with_id( - app, - AETHER_OPEN_ICE_MENU_ID, - "Open iCE", - true, - Some("CmdOrCtrl+2"), - )?; - let open_browser_item = MenuItem::with_id( - app, - AETHER_OPEN_BROWSER_MENU_ID, - "Open Browser", - true, - Some("CmdOrCtrl+3"), - )?; - let toggle_aion_item = MenuItem::with_id( - app, - AETHER_TOGGLE_AION_MENU_ID, - "Toggle AiON", - true, - Some("CmdOrCtrl+Shift+A"), - )?; - let capture_page_item = MenuItem::with_id( - app, - AETHER_CAPTURE_PAGE_MENU_ID, - "Capture Current Page", - true, - Some("CmdOrCtrl+Shift+C"), - )?; - let shortcuts_menu = Submenu::with_items( - app, - "Shortcuts", - true, - &[ - &focus_address_item, - &new_tab_item, - &find_item, - &open_dashboard_item, - &open_ice_item, - &open_browser_item, - &toggle_aion_item, - &capture_page_item, - ], - )?; - // Standard Edit submenu. Its predefined items carry the native key - // equivalents (Cmd/Ctrl+A/C/V/X and undo/redo), which is what wires up - // select-all/copy/paste in the address bar and other text fields. An - // empty Menu::new has no Edit menu, so those shortcuts would do nothing. - let edit_menu = Submenu::with_items( - app, - "Edit", - true, - &[ - &PredefinedMenuItem::undo(app, None)?, - &PredefinedMenuItem::redo(app, None)?, - &PredefinedMenuItem::separator(app)?, - &PredefinedMenuItem::cut(app, None)?, - &PredefinedMenuItem::copy(app, None)?, - &PredefinedMenuItem::paste(app, None)?, - &PredefinedMenuItem::select_all(app, None)?, - ], - )?; - menu.append(&edit_menu)?; - menu.append(&shortcuts_menu)?; - Ok(menu) - }) - .on_menu_event(|app, event| { - match event.id().as_ref() { - AETHER_FIND_MENU_ID => { - let _ = app.emit(AETHER_FIND_REQUESTED_EVENT, ()); - } - AETHER_FOCUS_ADDRESS_MENU_ID => { - let _ = app.emit(AETHER_SHORTCUT_EVENT, "focus-address"); - } - AETHER_NEW_TAB_MENU_ID => { - let _ = app.emit(AETHER_SHORTCUT_EVENT, "new-tab"); - } - AETHER_OPEN_DASHBOARD_MENU_ID => { - let _ = app.emit(AETHER_SHORTCUT_EVENT, "open-dashboard"); - } - AETHER_OPEN_ICE_MENU_ID => { - let _ = app.emit(AETHER_SHORTCUT_EVENT, "open-ice"); - } - AETHER_OPEN_BROWSER_MENU_ID => { - let _ = app.emit(AETHER_SHORTCUT_EVENT, "open-browser"); - } - AETHER_TOGGLE_AION_MENU_ID => { - let _ = app.emit(AETHER_SHORTCUT_EVENT, "toggle-aion"); - } - AETHER_CAPTURE_PAGE_MENU_ID => { - let _ = app.emit(AETHER_SHORTCUT_EVENT, "capture-page"); - } - _ => {} - } - }); - - let builder = builder.plugin(tauri_plugin_opener::init()); - #[cfg(target_os = "android")] - let builder = builder.plugin(android_tabs::init()); - - builder - .setup(|app| { - let app_data_dir = app.path().app_data_dir().expect("app data dir"); - app.manage(Backend::new(app_data_dir)); - - // Restore the previous session before anything reads tab state or - // prewarms a webview, so the restored active tab is the one warmed. - #[cfg(desktop)] - let restored_window = { - let app_handle = app.handle().clone(); - let state = app_handle.state::(); - match tauri::async_runtime::block_on(load_session(&state.paths.session_path)) { - Ok(session) => { - restore_session_tabs(&state, &session); - session.window - } - Err(error) => { - eprintln!("aether: could not read session: {error}"); - None - } - } - }; - - #[cfg(desktop)] - if let Some(window) = app.get_window("main") { - if let Some(geometry) = restored_window { - apply_session_window(&window, geometry); - } - - let app_handle = app.handle().clone(); - window.on_window_event(move |event| { - match event { - WindowEvent::Resized(_) | WindowEvent::ScaleFactorChanged { .. } => { - let state = app_handle.state::(); - let _ = resize_native_webviews(&app_handle, &state); - schedule_window_geometry_save(&app_handle); - } - WindowEvent::Moved(_) => schedule_window_geometry_save(&app_handle), - // force_exit() follows a close, so this is the last chance to - // capture the final geometry the throttle may have skipped. - WindowEvent::CloseRequested { .. } | WindowEvent::Destroyed => { - save_window_geometry_now(&app_handle) - } - _ => {} - } - }); - } - #[cfg(desktop)] - { - let app_handle = app.handle().clone(); - let state = app_handle.state::(); - if let Ok(active_tab_id) = active_tab_id(&state) { - if let Err(error) = ensure_native_webview(&app_handle, &state, &active_tab_id) { - eprintln!("Æther browser webview prewarm failed: {error}"); - } - } - prewarm_local_models(&app_handle); - } - Ok(()) - }) - .invoke_handler(tauri::generate_handler![ - aether_state, - aether_apps_list, - aether_apps_activate, - aether_apps_navigate, - aether_apps_go_back, - aether_apps_go_forward, - aether_tabs_list, - aether_tabs_create, - aether_tabs_activate, - aether_tabs_close, - aether_tabs_navigate, - aether_tabs_scroll_to_text, - aether_tabs_find, - aether_tabs_go_back, - aether_tabs_go_forward, - aether_tabs_report_native_event, - aether_tabs_thumbnail, - aether_layout_window_insets, - aether_layout_set_web_content_bounds, - aether_dashboard_open, - aether_hub_list, - aether_hub_create, - aether_hub_reorder, - aether_hub_delete, - aether_collections_list, - aether_collections_create, - aether_collections_update, - aether_collections_reorder, - aether_collections_delete, - aether_collections_captures, - aether_capture_current_page, - aether_capture_url, - aether_capture_urls, - aether_capture_move, - aether_capture_delete, - aether_capture_suggest_hub, - aether_search_collection, - aether_search_library, - aether_semantic_trail_generate, - aether_flow_graph, - aether_air_prepare, - aether_air_render, - aether_air_list_recent, - aether_air_open, - aether_air_reveal, - aether_chat_ask, - aether_chat_cancel, - aether_chat_history, - aether_chat_clear_history, - aether_crystallizer_generate, - aether_crystallizer_list_saved, - aether_crystallizer_get_saved, - aether_crystallizer_save, - aether_crystallizer_reorder_saved, - aether_crystallizer_delete_saved, - aether_system_status, - aether_system_settings, - aether_system_update_settings, - aether_system_update_models, - aether_system_check_for_update, - aether_system_download_models, - aether_system_export_library, - aether_library_reindex, - aether_library_index_status, - aether_system_open_external_url, - aether_layout_set_panel_collapsed, - aether_layout_set_modal_overlay_open, - aether_layout_show_status_toast - ]) - .build(tauri::generate_context!()) - .expect("error while building Æther") - .run(|_app_handle, _event| { - #[cfg(desktop)] - if let tauri::RunEvent::ExitRequested { .. } = _event { - force_exit(); - } - }); -} - -#[cfg(desktop)] -fn prewarm_local_models(app: &AppHandle) { - let state = app.state::(); - let paths = state.paths.clone(); - let runtime = Arc::clone(&state.native_runtime); - - tauri::async_runtime::spawn(async move { - let Ok(settings) = load_settings(&paths.settings_path).await else { - return; - }; - let catalog = model_catalog(&paths, &settings.local_model); - let chat_model = catalog.chat_model; - let embedding_model = catalog.embedding_model; - if chat_model.is_none() && embedding_model.is_none() { - return; - } - let result = task::spawn_blocking(move || { - let mut runtime = runtime - .lock() - .map_err(|_| "Local model runtime is unavailable.".to_string())?; - if let Some(model_path) = &chat_model { - runtime - .ensure_model(NativeModelKind::Chat, model_path) - .map_err(|error| { - format!("chat model {} failed: {error}", model_label(model_path)) - })?; - } - if let Some(model_path) = &embedding_model { - runtime.warm_embedding_model(model_path).map_err(|error| { - format!( - "embedding model {} failed: {error}", - model_label(model_path) - ) - })?; - } - Ok::<(), String>(()) - }) - .await; - - match result { - Ok(Ok(())) => {} - Ok(Err(error)) => eprintln!("Æther model prewarm failed: {error}"), - Err(error) => eprintln!("Æther model prewarm task failed: {error}"), - } - }); -} - -#[tauri::command] -fn aether_state(state: State) -> Cmd { - Ok(lock_tabs(&state)?.state()) -} - -#[tauri::command] -fn aether_apps_list(state: State) -> Cmd> { - Ok(lock_tabs(&state)?.apps()) -} - -#[tauri::command(rename_all = "camelCase")] -fn aether_apps_activate(app: AppHandle, state: State, app_id: String) -> Cmd<()> { - if app_id != "browser" { - return Err(format!("Unknown app: {app_id}")); - } - { - let mut tabs = lock_tabs(&state)?; - tabs.dashboard_open = false; - } - let active_tab_id = lock_tabs(&state)?.active_tab_id.clone(); - ensure_native_webview(&app, &state, &active_tab_id)?; - emit_state(&app, &state) -} - -#[tauri::command(rename_all = "camelCase")] -async fn aether_apps_navigate( - app: AppHandle, - state: State<'_, Backend>, - app_id: String, - url: String, -) -> Cmd<()> { - if app_id != "browser" { - return Err(format!("Unknown app: {app_id}")); - } - navigate_active_tab(&app, &state, &url).await -} - -#[tauri::command(rename_all = "camelCase")] -fn aether_apps_go_back(app: AppHandle, state: State, app_id: String) -> Cmd<()> { - if app_id != "browser" { - return Err(format!("Unknown app: {app_id}")); - } - aether_tabs_go_back(app, state, String::new()) -} - -#[tauri::command(rename_all = "camelCase")] -fn aether_apps_go_forward(app: AppHandle, state: State, app_id: String) -> Cmd<()> { - if app_id != "browser" { - return Err(format!("Unknown app: {app_id}")); - } - aether_tabs_go_forward(app, state, String::new()) -} - -#[tauri::command] -fn aether_tabs_list(state: State) -> Cmd> { - Ok(lock_tabs(&state)?.tabs()) -} - -#[tauri::command] -async fn aether_tabs_create( - app: AppHandle, - state: State<'_, Backend>, - input: Option, -) -> Cmd { - let settings = load_settings(&state.paths.settings_path).await?; - // No URL → open a blank start-page tab (Portals + search) rather than a search engine. - let requested_url = input.and_then(|input| input.url); - let url = match requested_url { - Some(raw_url) => normalize_url(&raw_url, &settings.browser.default_search_engine), - None => START_PAGE_URL.to_string(), - }; - let tab = ManagedTab::new("browser", &url); - let tab_id = tab.id.clone(); - let summary = tab.summary(true); - { - let mut tabs = lock_tabs(&state)?; - tabs.active_tab_id = tab.id.clone(); - tabs.active_app_id = tab.app_id.clone(); - tabs.dashboard_open = false; - tabs.tabs.push(tab); - } - ensure_native_webview(&app, &state, &tab_id)?; - emit_state(&app, &state)?; - schedule_session_save(&app); - Ok(summary) -} - -#[tauri::command(rename_all = "camelCase")] -fn aether_tabs_activate(app: AppHandle, state: State, tab_id: String) -> Cmd<()> { - { - let mut tabs = lock_tabs(&state)?; - if !tabs.tabs.iter().any(|tab| tab.id == tab_id) { - return Err(format!("Unknown tab: {tab_id}")); - } - tabs.active_tab_id = tab_id.clone(); - tabs.active_app_id = "browser".to_string(); - tabs.dashboard_open = false; - } - ensure_native_webview(&app, &state, &tab_id)?; - emit_state(&app, &state)?; - schedule_session_save(&app); - Ok(()) -} - -#[tauri::command(rename_all = "camelCase")] -fn aether_tabs_close(app: AppHandle, state: State, tab_id: String) -> Cmd<()> { - let mut next_active_tab_id = None; - { - let mut tabs = lock_tabs(&state)?; - if tabs.tabs.len() == 1 { - return Ok(()); - } - let was_active = tabs.active_tab_id == tab_id; - tabs.tabs.retain(|tab| tab.id != tab_id); - if was_active { - if let Some(next_id) = tabs.tabs.last().map(|tab| tab.id.clone()) { - tabs.active_tab_id = next_id.clone(); - next_active_tab_id = Some(next_id); - } - } - } - close_native_webview(&app, &state, &tab_id)?; - if let Some(active_tab_id) = next_active_tab_id { - ensure_native_webview(&app, &state, &active_tab_id)?; - } else { - sync_native_webview_visibility(&app, &state)?; - } - emit_state(&app, &state)?; - schedule_session_save(&app); - Ok(()) -} - -#[tauri::command(rename_all = "camelCase")] -async fn aether_tabs_navigate( - app: AppHandle, - state: State<'_, Backend>, - tab_id: String, - url: String, -) -> Cmd<()> { - let settings = load_settings(&state.paths.settings_path).await?; - let target_url = { - let mut tabs = lock_tabs(&state)?; - let tab = tabs - .tabs - .iter_mut() - .find(|tab| tab.id == tab_id) - .ok_or_else(|| format!("Unknown tab: {tab_id}"))?; - tab.navigate(&url, &settings.browser.default_search_engine); - let target_url = tab.url.clone(); - tabs.active_tab_id = tab.id.clone(); - tabs.dashboard_open = false; - target_url - }; - navigate_native_webview(&app, &state, &tab_id, &target_url)?; - emit_state(&app, &state)?; - schedule_session_save(&app); - Ok(()) -} - -#[tauri::command(rename_all = "camelCase")] -fn aether_tabs_scroll_to_text( - app: AppHandle, - state: State, - tab_id: String, - text: String, -) -> Cmd<()> { - { - let tabs = lock_tabs(&state)?; - if !tabs.tabs.iter().any(|tab| tab.id == tab_id) { - return Err(format!("Unknown tab: {tab_id}")); - } - } - scroll_native_webview_to_text(&app, &state, &tab_id, &text) -} - -#[tauri::command(rename_all = "camelCase")] -fn aether_tabs_find( - app: AppHandle, - state: State, - tab_id: String, - query: Option, - action: Option, -) -> Cmd<()> { - let target_tab_id = { - let tabs = lock_tabs(&state)?; - if tab_id.is_empty() { - tabs.active_tab_id.clone() - } else if tabs.tabs.iter().any(|tab| tab.id == tab_id) { - tab_id - } else { - return Err(format!("Unknown tab: {tab_id}")); - } - }; - let action = action.as_deref().unwrap_or("find"); - find_native_webview_text(&app, &state, &target_tab_id, query.as_deref(), action) -} - -#[tauri::command(rename_all = "camelCase")] -fn aether_tabs_go_back(app: AppHandle, state: State, tab_id: String) -> Cmd<()> { - let mut restore_start_page = false; - let target_tab_id = { - let mut tabs = lock_tabs(&state)?; - let tab = if tab_id.is_empty() { - tabs.active_tab_mut() - .ok_or_else(|| "No active browser tab.".to_string())? - } else { - tabs.tabs - .iter_mut() - .find(|tab| tab.id == tab_id) - .ok_or_else(|| format!("Unknown tab: {tab_id}"))? - }; - // The start page is a renderer overlay, not a native page, so the webview can't - // navigate back to it. When the previous history entry is the start page, park - // the tab back on it (its webview is kept hidden for a later forward). - if tab.can_go_back() - && tab.history.get(tab.history_index - 1).map(String::as_str) == Some(START_PAGE_URL) - { - tab.history_index -= 1; - tab.url = START_PAGE_URL.to_string(); - tab.title = "New tab".to_string(); - tab.is_loading = false; - restore_start_page = true; - } - tab.id.clone() - }; - if restore_start_page { - sync_native_webview_visibility(&app, &state)?; - } else { - navigate_native_webview_history( - &app, - &state, - &target_tab_id, - WebviewHistoryDirection::Back, - )?; - } - emit_state(&app, &state) -} - -#[tauri::command(rename_all = "camelCase")] -fn aether_tabs_go_forward(app: AppHandle, state: State, tab_id: String) -> Cmd<()> { - let mut leave_start_page = false; - let target_tab_id = { - let mut tabs = lock_tabs(&state)?; - let tab = if tab_id.is_empty() { - tabs.active_tab_mut() - .ok_or_else(|| "No active browser tab.".to_string())? - } else { - tabs.tabs - .iter_mut() - .find(|tab| tab.id == tab_id) - .ok_or_else(|| format!("Unknown tab: {tab_id}"))? - }; - // Forwarding off the start page: advance to the real page whose (hidden) webview - // we kept, then reveal it instead of issuing a native history.forward(). - if tab.url == START_PAGE_URL && tab.can_go_forward() { - tab.history_index += 1; - tab.url = tab.history[tab.history_index].clone(); - tab.title = title_from_url(&tab.url); - tab.is_loading = false; - leave_start_page = true; - } - tab.id.clone() - }; - if leave_start_page { - ensure_native_webview(&app, &state, &target_tab_id)?; - } else { - navigate_native_webview_history( - &app, - &state, - &target_tab_id, - WebviewHistoryDirection::Forward, - )?; - } - emit_state(&app, &state) -} - -// Mobile-only feedback channel: the Kotlin TabsPlugin evaluates -// `window.__AETHER_TAB_EVENT__(...)` in the main webview and the renderer -// forwards the payload here, mirroring how desktop child-webview callbacks -// (on_navigation / on_page_load / on_document_title_changed) feed tab state. -#[tauri::command] -fn aether_tabs_report_native_event( - app: AppHandle, - state: State, - input: NativeTabEventInput, -) -> Cmd<()> { - match input.kind.as_str() { - "navigation" => { - let parked_on_start_page = { - let tabs = lock_tabs(&state)?; - tabs.tabs - .iter() - .find(|tab| tab.id == input.tab_id) - .map(|tab| tab.url == START_PAGE_URL) - .unwrap_or(true) - }; - // A tab parked on the start page keeps its (hidden) webview alive; - // ignore its stray events so the start-page sentinel survives. - if parked_on_start_page { - return Ok(()); - } - if let Some(url) = input.url.as_deref() { - update_tab_navigation_state( - &state, - &input.tab_id, - url, - input.is_loading.unwrap_or(false), - ); - } - { - let mut tabs = lock_tabs(&state)?; - if let Some(tab) = tabs.tabs.iter_mut().find(|tab| tab.id == input.tab_id) { - tab.native_can_go_back = input.can_go_back; - tab.native_can_go_forward = input.can_go_forward; - } - } - emit_state(&app, &state) - } - "title" => { - if let Some(title) = input.title.as_deref() { - update_tab_title(&state, &input.tab_id, title); - } - emit_state(&app, &state) - } - "find" => app - .emit( - AETHER_FIND_RESULT_EVENT, - FindResultPayload { - tab_id: input.tab_id, - current: input.current.unwrap_or(0), - total: input.total.unwrap_or(0), - }, - ) - .map_err(|error| error.to_string()), - _ => Ok(()), - } -} - -// Preview image (data-URI JPEG) for the mobile tab-grid switcher. Desktop -// renders live child webviews and never asks for one, so it returns None. -// Async on purpose: the Kotlin side resolves on the Android UI thread, so the -// blocking run_mobile_plugin call must sit on a tokio worker. -#[tauri::command(rename_all = "camelCase")] -async fn aether_tabs_thumbnail(app: AppHandle, tab_id: String) -> Cmd> { - #[cfg(target_os = "android")] - { - let response: android_tabs::ThumbnailResponse = app - .state::() - .run_for("thumbnail", android_tabs::TabPayload { tab_id: &tab_id })?; - return Ok(response.image); - } - #[allow(unreachable_code)] - { - let _ = (app, tab_id); - Ok(None) - } -} - -// System-bar/cutout insets (CSS px) for the edge-to-edge Android activity; -// zero on desktop, where the OS window frame handles this. Async for the same -// UI-thread reason as aether_tabs_thumbnail. -#[tauri::command] -async fn aether_layout_window_insets(app: AppHandle) -> Cmd { - #[cfg(target_os = "android")] - { - let response: android_tabs::InsetsResponse = app - .state::() - .run_for("insets", serde_json::json!({}))?; - return serde_json::to_value(response).map_err(|error| error.to_string()); - } - #[allow(unreachable_code)] - { - let _ = app; - serde_json::to_value(serde_json::json!({ - "top": 0.0, "bottom": 0.0, "left": 0.0, "right": 0.0 - })) - .map_err(|error| error.to_string()) - } -} - -// The renderer measures where Android tab WebViews belong (MobileTabView's -// The renderer measures the slot where live web content belongs (a placeholder div's -// bounding rect, CSS px) and reports it here. Both shells position their native web -// views from this, so a chrome restyle or a panel resize moves the content with it -// instead of drifting away from hardcoded offsets. -#[tauri::command(rename_all = "camelCase")] -fn aether_layout_set_web_content_bounds( - app: AppHandle, - state: State, - top: f64, - left: f64, - width: f64, - height: f64, -) -> Cmd<()> { - let next = WebContentBounds { - top, - left, - width, - height, - }; - { - let mut stored = state - .web_content_bounds - .lock() - .map_err(|_| "Æther layout bounds are unavailable.".to_string())?; - // A ResizeObserver fires on every layout pass; repositioning native webviews - // for an unchanged rect causes visible flicker on desktop. - if *stored == next { - return Ok(()); - } - *stored = next; - } - sync_native_webview_visibility(&app, &state) -} - -#[tauri::command] -fn aether_dashboard_open(app: AppHandle, state: State) -> Cmd<()> { - { - let mut tabs = lock_tabs(&state)?; - tabs.dashboard_open = true; - } - sync_native_webview_visibility(&app, &state)?; - emit_state(&app, &state) -} - -#[tauri::command] -async fn aether_hub_list(state: State<'_, Backend>) -> Cmd> { - Ok(load_library(&state.paths.library_path).await?.shortcuts) -} - -#[tauri::command] -async fn aether_hub_create( - state: State<'_, Backend>, - input: CreateShortcutInput, -) -> Cmd { - let title = input.title.trim().to_string(); - if title.is_empty() { - return Err("Shortcut title is required.".to_string()); - } - let url = normalize_url(&input.url, "google"); - let mut data = load_library(&state.paths.library_path).await?; - let favicon = input - .favicon - .as_deref() - .map(str::trim) - .filter(|favicon| !favicon.is_empty()) - .map(str::to_string); - let theme_color = input.theme_color.as_deref().and_then(normalize_theme_color); - if let Some(existing) = data - .shortcuts - .iter_mut() - .find(|shortcut| shortcut.url == url) - { - let mut changed = false; - if existing.favicon.is_none() && favicon.is_some() { - existing.favicon = favicon; - changed = true; - } - if existing.theme_color.is_none() && theme_color.is_some() { - existing.theme_color = theme_color; - changed = true; - } - let shortcut = existing.clone(); - if changed { - save_json(&state.paths.library_path, &data).await?; - } - return Ok(shortcut); - } - let shortcut = HubShortcutSummary { - id: uuid(), - title, - host: get_tab_host(&url), - url, - created_at: now(), - favicon, - theme_color, - }; - data.shortcuts.insert(0, shortcut.clone()); - save_json(&state.paths.library_path, &data).await?; - Ok(shortcut) -} - -#[tauri::command] -async fn aether_hub_reorder( - state: State<'_, Backend>, - ids: Vec, -) -> Cmd> { - let mut data = load_library(&state.paths.library_path).await?; - data.shortcuts = reorder(data.shortcuts, &ids, |shortcut| &shortcut.id); - save_json(&state.paths.library_path, &data).await?; - Ok(data.shortcuts) -} - -#[tauri::command] -async fn aether_hub_delete(state: State<'_, Backend>, id: String) -> Cmd<()> { - let mut data = load_library(&state.paths.library_path).await?; - data.shortcuts.retain(|shortcut| shortcut.id != id); - save_json(&state.paths.library_path, &data).await -} - -#[tauri::command] -async fn aether_collections_list(state: State<'_, Backend>) -> Cmd> { - Ok(load_library(&state.paths.library_path).await?.collections) -} - -#[tauri::command] -async fn aether_collections_create( - state: State<'_, Backend>, - input: CreateCollectionInput, -) -> Cmd { - let name = input.name.trim().to_string(); - if name.is_empty() { - return Err("Collection name is required.".to_string()); - } - let mut data = load_library(&state.paths.library_path).await?; - let now = now(); - let existing = data - .collections - .iter() - .map(|collection| collection.id.clone()) - .collect::>(); - let collection = CollectionSummary { - id: unique_slug(&name, &existing), - name, - description: input.description.unwrap_or_default().trim().to_string(), - icon: Some(input.icon.unwrap_or_else(|| "book".to_string())) - .map(|icon| icon.trim().to_string()) - .filter(|icon| !icon.is_empty()), - created_at: now.clone(), - updated_at: now, - capture_count: 0, - chunk_count: 0, - }; - data.collections.push(collection.clone()); - save_json(&state.paths.library_path, &data).await?; - Ok(collection) -} - -#[tauri::command] -async fn aether_collections_update( - state: State<'_, Backend>, - input: UpdateCollectionInput, -) -> Cmd { - let mut data = load_library(&state.paths.library_path).await?; - let collection = data - .collections - .iter_mut() - .find(|collection| collection.id == input.id) - .ok_or_else(|| "Collection not found.".to_string())?; - if let Some(name) = input.name { - let name = name.trim(); - if name.is_empty() { - return Err("Collection name is required.".to_string()); - } - collection.name = name.to_string(); - } - if let Some(description) = input.description { - collection.description = description.trim().to_string(); - } - if let Some(icon) = input.icon { - collection.icon = Some(icon.trim().to_string()).filter(|icon| !icon.is_empty()); - } - collection.updated_at = now(); - let updated = collection.clone(); - save_json(&state.paths.library_path, &data).await?; - Ok(updated) -} - -#[tauri::command] -async fn aether_collections_reorder( - state: State<'_, Backend>, - ids: Vec, -) -> Cmd> { - let mut data = load_library(&state.paths.library_path).await?; - data.collections = reorder(data.collections, &ids, |collection| &collection.id); - save_json(&state.paths.library_path, &data).await?; - Ok(data.collections) -} - -#[tauri::command] -async fn aether_collections_delete(state: State<'_, Backend>, id: String) -> Cmd<()> { - let mut library = load_library(&state.paths.library_path).await?; - library.collections.retain(|collection| collection.id != id); - library - .captures - .retain(|capture| capture.collection_id != id); - save_json(&state.paths.library_path, &library).await?; - - with_vectors_mut(&state, |vectors| { - vectors.chunks.retain(|chunk| chunk.collection_id != id); - }) - .await -} - -#[tauri::command(rename_all = "camelCase")] -async fn aether_collections_captures( - state: State<'_, Backend>, - collection_id: String, -) -> Cmd> { - let mut captures = load_library(&state.paths.library_path) - .await? - .captures - .into_iter() - .filter(|capture| capture.collection_id == collection_id) - .collect::>(); - captures.sort_by(|left, right| right.captured_at.cmp(&left.captured_at)); - Ok(captures) -} - -// Strict counterpart to normalize_url: capture must never silently turn a typo into -// a search-engine URL and then index the results page. Bare hosts are still -// accepted, since that is how people paste links. -fn capture_target_url(raw: &str) -> Cmd { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Err("Enter a web address to capture.".to_string()); - } - let lowered = trimmed.to_ascii_lowercase(); - let candidate = if lowered.starts_with("http://") || lowered.starts_with("https://") { - trimmed.to_string() - } else if lowered.contains("://") { - return Err("Only http and https pages can be captured.".to_string()); - } else if !trimmed.contains(char::is_whitespace) && trimmed.contains('.') { - format!("https://{trimmed}") - } else { - return Err(format!("\"{trimmed}\" is not a web address.")); - }; - let parsed = - Url::parse(&candidate).map_err(|_| format!("\"{trimmed}\" is not a web address."))?; - if parsed.host_str().unwrap_or_default().is_empty() { - return Err(format!("\"{trimmed}\" is not a web address.")); - } - Ok(parsed.to_string()) -} - -#[tauri::command] -async fn aether_capture_current_page( - app: AppHandle, - state: State<'_, Backend>, - input: CaptureCurrentPageInput, -) -> Cmd { - emit_capture_progress(&app, "Reading current page", None, None); - let active_tab = { - let tabs = lock_tabs(&state)?; - if tabs.dashboard_open { - return Err("Open a website before capturing into a collection.".to_string()); - } - tabs.active_tab() - .cloned() - .ok_or_else(|| "No active browser tab.".to_string())? - }; - let app_id = active_tab.app_id.clone(); - let captured = extract_readable_active_page(&state, &active_tab).await?; - capture_page_into_collection(&app, &state, &input.collection_id, captured, &app_id).await -} - -// Captures a page ÆTHER never had to load. This is what lets sources arrive from a -// pasted link, a dropped link, or a batch of open tabs instead of only from the -// active tab, so the library can grow without the app being the default browser. -#[tauri::command] -async fn aether_capture_url( - app: AppHandle, - state: State<'_, Backend>, - input: CaptureUrlInput, -) -> Cmd { - let target = capture_target_url(&input.url)?; - emit_capture_progress(&app, "Fetching page", None, None); - let captured = extract_readable_page(&state.client, &target).await?; - capture_page_into_collection(&app, &state, &input.collection_id, captured, "browser").await -} - -// Bulk sibling of aether_capture_url. One bad link in a batch must not discard the -// pages that did work, so failures are reported per URL instead of aborting. -#[tauri::command] -async fn aether_capture_urls( - app: AppHandle, - state: State<'_, Backend>, - input: CaptureUrlsInput, -) -> Cmd { - if input.urls.is_empty() { - return Err("No links to capture.".to_string()); - } - let collection = get_collection(&state.paths.library_path, &input.collection_id).await?; - - let total = input.urls.len(); - let mut captured = Vec::new(); - let mut failures = Vec::new(); - - for (index, raw_url) in input.urls.iter().enumerate() { - emit_capture_progress( - &app, - &format!("Capturing link {} of {total}", index + 1), - Some(index), - Some(total), - ); - let outcome = async { - let target = capture_target_url(raw_url)?; - let page = extract_readable_page(&state.client, &target).await?; - capture_page_into_collection(&app, &state, &input.collection_id, page, "browser").await - } - .await; - - match outcome { - Ok(result) => captured.push(result.capture), - Err(reason) => failures.push(BulkCaptureFailure { - url: raw_url.clone(), - reason, - }), - } - } - - emit_capture_progress(&app, "Finished capturing links", Some(total), Some(total)); - - Ok(BulkCaptureResult { - captured, - collection_name: collection.name, - failures, - }) -} - -// Shared tail of every capture path: chunk, embed, store vectors, update the -// library manifest. Kept in one place so the fetch-based captures cannot drift -// from the active-tab capture. -async fn capture_page_into_collection( - app: &AppHandle, - state: &State<'_, Backend>, - collection_id: &str, - captured: CapturedPage, - app_id: &str, -) -> Cmd { - let settings = load_settings(&state.paths.settings_path).await?; - let mut library = load_library(&state.paths.library_path).await?; - let collection = library - .collections - .iter() - .find(|collection| collection.id == collection_id) - .cloned() - .ok_or_else(|| "Collection not found.".to_string())?; - emit_capture_progress(app, "Chunking readable text", None, None); - let captured_key = normalize_capture_url_key(&captured.url); - if library.captures.iter().any(|capture| { - capture.collection_id == collection.id - && normalize_capture_url_key(&capture.url) == captured_key - }) { - return Err(format!("Page is already in {}.", collection.name)); - } - - let (chunk_size, chunk_overlap) = capture_chunk_settings(&state.paths, &settings); - let chunks = split_text(&captured.text, chunk_size, chunk_overlap); - if chunks.is_empty() { - return Err("No readable text found on that page.".to_string()); - } - emit_capture_progress( - app, - &format!("Embedding {} chunks", chunks.len()), - Some(0), - Some(chunks.len()), - ); - let embeddings = local_embed_with_progress( - state, - &settings, - chunks.clone(), - Some(EmbeddingProgress { - app: app.clone(), - message: "Embedding chunks".to_string(), - }), - ) - .await?; - if embeddings.len() != chunks.len() { - return Err( - "Local embedding model returned an unexpected number of embeddings.".to_string(), - ); - } - emit_capture_progress( - app, - "Saving capture", - Some(chunks.len()), - Some(chunks.len()), - ); - - let capture_id = uuid(); - let captured_at = now(); - let records = chunks - .into_iter() - .enumerate() - .map(|(index, text)| ChunkRecord { - id: uuid(), - vector: embeddings[index].clone(), - // Assigned by push_chunks when the store accepts the record. - vector_slot: 0, - needs_reembed: false, - text, - collection_id: collection.id.clone(), - capture_id: capture_id.clone(), - title: captured.title.clone(), - url: captured.url.clone(), - app_id: app_id.to_string(), - captured_at: captured_at.clone(), - chunk_index: index, - }) - .collect::>(); - - // push_chunks assigns the sidecar slots; never extend `chunks` directly. - with_vectors_mut(state, |vectors| { - vectors.push_chunks(records.iter().cloned()); - }) - .await?; - - let capture = CaptureSummary { - id: capture_id, - collection_id: collection.id.clone(), - title: captured.title, - url: captured.url, - app_id: app_id.to_string(), - captured_at, - chunk_count: records.len(), - metadata: None, - }; - library.captures.push(capture.clone()); - if let Some(stored_collection) = library - .collections - .iter_mut() - .find(|item| item.id == collection.id) - { - stored_collection.capture_count += 1; - stored_collection.chunk_count += records.len(); - stored_collection.updated_at = capture.captured_at.clone(); - } - save_json(&state.paths.library_path, &library).await?; - - Ok(CaptureResult { - capture, - collection_name: collection.name, - }) -} - -#[tauri::command] -async fn aether_capture_move( - state: State<'_, Backend>, - input: MoveCaptureInput, -) -> Cmd { - let mut library = load_library(&state.paths.library_path).await?; - let now = now(); - let target_exists = library - .collections - .iter() - .any(|collection| collection.id == input.collection_id); - if !target_exists { - return Err("Target collection not found.".to_string()); - } - let capture = library - .captures - .iter_mut() - .find(|capture| capture.id == input.capture_id) - .ok_or_else(|| "Capture not found.".to_string())?; - if capture.collection_id == input.collection_id { - return Ok(capture.clone()); - } - let source_collection_id = capture.collection_id.clone(); - let chunk_count = capture.chunk_count; - capture.collection_id = input.collection_id.clone(); - let moved = capture.clone(); - for collection in &mut library.collections { - if collection.id == source_collection_id { - collection.capture_count = collection.capture_count.saturating_sub(1); - collection.chunk_count = collection.chunk_count.saturating_sub(chunk_count); - collection.updated_at = now.clone(); - } - if collection.id == input.collection_id { - collection.capture_count += 1; - collection.chunk_count += chunk_count; - collection.updated_at = now.clone(); - } - } - save_json(&state.paths.library_path, &library).await?; - - with_vectors_mut(&state, |vectors| { - for chunk in &mut vectors.chunks { - if chunk.capture_id == input.capture_id { - chunk.collection_id = input.collection_id.clone(); - } - } - }) - .await?; - Ok(moved) -} - -#[tauri::command(rename_all = "camelCase")] -async fn aether_capture_delete(state: State<'_, Backend>, capture_id: String) -> Cmd<()> { - let mut library = load_library(&state.paths.library_path).await?; - let deleted = library - .captures - .iter() - .find(|capture| capture.id == capture_id) - .cloned(); - library.captures.retain(|capture| capture.id != capture_id); - if let Some(deleted) = deleted { - if let Some(collection) = library - .collections - .iter_mut() - .find(|collection| collection.id == deleted.collection_id) - { - collection.capture_count = collection.capture_count.saturating_sub(1); - collection.chunk_count = collection.chunk_count.saturating_sub(deleted.chunk_count); - collection.updated_at = now(); - } - } - save_json(&state.paths.library_path, &library).await?; - with_vectors_mut(&state, |vectors| { - vectors - .chunks - .retain(|chunk| chunk.capture_id != capture_id); - }) - .await -} - -#[tauri::command] -async fn aether_search_collection( - state: State<'_, Backend>, - input: SearchCollectionInput, -) -> Cmd> { - search_collection(&state, input).await -} - -#[tauri::command] -async fn aether_semantic_trail_generate( - state: State<'_, Backend>, - input: Option, -) -> Cmd { - semantic_trail_generate( - &state, - input.unwrap_or(SemanticTrailInput { - query: None, - limit: None, - }), - ) - .await -} - -#[tauri::command] -async fn aether_flow_graph( - state: State<'_, Backend>, - input: Option, -) -> Cmd { - flow_graph_generate( - &state, - input.unwrap_or(FlowGraphInput { - query: None, - source_limit: None, - }), - ) - .await -} - -#[tauri::command] -async fn aether_air_prepare( - state: State<'_, Backend>, - input: AirDossierInput, -) -> Cmd { - air_prepare_dossier(&state, input, false).await -} - -#[tauri::command] -async fn aether_air_render( - state: State<'_, Backend>, - input: AirDossierInput, -) -> Cmd { - let prepared = air_prepare_dossier(&state, input, true).await?; - let output_dir = resolve_air_export_dir(&state.paths, true).await?; - let filename = air_dossier_filename(&prepared.title, &prepared.generated_at); - let path = output_dir.join(filename); - tokio::fs::write(&path, prepared.markdown_preview.as_bytes()) - .await - .map_err(|error| error.to_string())?; - Ok(AirRenderResult { - path: path.display().to_string(), - filename: path - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("aether-dossier.md") - .to_string(), - title: prepared.title, - source_count: prepared.sources.len(), - rendered_at: prepared.generated_at, - }) -} - -#[tauri::command] -async fn aether_air_list_recent(state: State<'_, Backend>) -> Cmd> { - air_list_recent(&state.paths).await -} - -#[tauri::command(rename_all = "camelCase")] -fn aether_air_open(app: AppHandle, path: String) -> Cmd<()> { - let path = PathBuf::from(path); - if !path.exists() { - return Err("AiR file not found.".to_string()); - } - app.opener() - .open_path(path.display().to_string(), None::) - .map_err(|error| error.to_string()) -} - -#[tauri::command(rename_all = "camelCase")] -fn aether_air_reveal(app: AppHandle, path: String) -> Cmd<()> { - let path = PathBuf::from(path); - if !path.exists() { - return Err("AiR file not found.".to_string()); - } - app.opener() - .reveal_item_in_dir(path) - .map_err(|error| error.to_string()) -} - -#[tauri::command] -async fn aether_capture_suggest_hub( - state: State<'_, Backend>, -) -> Cmd> { - suggest_capture_hub(&state).await -} - -#[tauri::command] -async fn aether_chat_ask( - app: AppHandle, - state: State<'_, Backend>, - input: AskChatInput, -) -> Cmd { - let prompt = input.prompt.trim().to_string(); - if prompt.is_empty() { - return Err("Enter a question before asking Æther.".to_string()); - } - state - .generation_cancelled - .store(false, AtomicOrdering::Relaxed); - let stream = ChatStreamEmitter { - app, - request_id: input.request_id.clone().unwrap_or_else(uuid), - }; - let settings = load_settings(&state.paths.settings_path).await?; - let mut citations = if let Some(collection_id) = input.collection_id.clone() { - stream.status("Searching your knowledge hub"); - search_collection( - &state, - SearchCollectionInput { - collection_id, - query: prompt.clone(), - limit: Some(8), - }, - ) - .await? - } else { - Vec::new() - }; - - if input.include_current_page.unwrap_or(false) { - stream.status("Reading current page"); - if let Ok(active_url) = active_tab_url(&state) { - let active_tab = { - let tabs = lock_tabs(&state)?; - tabs.active_tab().cloned() - }; - let captured = if let Some(active_tab) = active_tab { - extract_readable_active_page(&state, &active_tab).await.ok() - } else { - extract_readable_page(&state.client, &active_url).await.ok() - }; - if let Some(captured) = captured { - // Give the current page fewer slots when a hub is also in play so the - // hub still contributes; let it use the full budget on its own. - let page_limit = if input.collection_id.is_some() { - 3 - } else { - chat_citation_limit() - }; - let page_citations = current_page_citations( - &state, - &settings, - captured, - &prompt, - input.collection_id.as_deref(), - page_limit, - ) - .await; - // Prepend so the current page takes priority over hub matches. - citations.splice(0..0, page_citations); - } - } - } - let citations = dedupe_citations(citations) - .into_iter() - .take(chat_citation_limit()) - .collect::>(); - - // Only the most recent turns are replayed; older ones stay on disk for reading but - // would otherwise crowd the retrieved sources out of the context window. - let thread = conversation_thread(&state.paths, input.collection_id.as_deref()).await; - let history = thread - .iter() - .rev() - .take(PROMPT_HISTORY_TURNS) - .rev() - .cloned() - .collect::>(); - - let result = local_chat(&state, &settings, &prompt, citations, &history, Some(stream)).await?; - - // A failed write must not discard the answer the user is already reading. - if let Err(error) = append_conversation_turn( - &state.paths, - input.collection_id.as_deref(), - ConversationTurn { - id: uuid(), - prompt: prompt.clone(), - answer: result.answer.clone(), - model: result.model.clone(), - asked_at: now(), - citations: result.citations.clone(), - metrics: result.metrics.clone(), - }, - ) - .await - { - eprintln!("aether: could not save conversation turn: {error}"); - } - - Ok(result) -} - -#[tauri::command(rename_all = "camelCase")] -async fn aether_chat_history( - state: State<'_, Backend>, - collection_id: Option, -) -> Cmd> { - Ok(conversation_thread(&state.paths, collection_id.as_deref()).await) -} - -#[tauri::command(rename_all = "camelCase")] -async fn aether_chat_clear_history( - state: State<'_, Backend>, - collection_id: Option, -) -> Cmd<()> { - let key = conversation_thread_key(collection_id.as_deref()); - let mut data = load_conversations(&state.paths.conversations_path).await?; - data.threads.remove(&key); - save_json(&state.paths.conversations_path, &data).await -} - -#[tauri::command] -async fn aether_crystallizer_generate( - state: State<'_, Backend>, - input: GenerateIcebergInput, -) -> Cmd { - let topic = input.keyword.trim().to_string(); - if topic.is_empty() { - return Err("Enter a topic before crystallizing.".to_string()); - } - state - .generation_cancelled - .store(false, AtomicOrdering::Relaxed); - let settings = load_settings(&state.paths.settings_path).await?; - local_generate_iceberg(&state, &settings, &topic).await -} - -#[tauri::command] -fn aether_chat_cancel(state: State) -> Cmd<()> { - state - .generation_cancelled - .store(true, AtomicOrdering::Relaxed); - Ok(()) -} - -#[tauri::command] -async fn aether_crystallizer_list_saved( - state: State<'_, Backend>, -) -> Cmd> { - Ok(load_icebergs(&state.paths.icebergs_path) - .await? - .icebergs - .iter() - .map(saved_iceberg_summary) - .collect()) -} - -#[tauri::command(rename_all = "camelCase")] -async fn aether_crystallizer_get_saved(state: State<'_, Backend>, id: String) -> Cmd { - load_icebergs(&state.paths.icebergs_path) - .await? - .icebergs - .into_iter() - .find(|iceberg| iceberg.id == id) - .ok_or_else(|| "Saved iceberg not found.".to_string()) -} - -#[tauri::command] -async fn aether_crystallizer_save( - state: State<'_, Backend>, - input: SaveIcebergInput, -) -> Cmd { - let title = input.title.trim().to_string(); - let keyword = input.keyword.trim().to_string(); - let model = input.model.trim().to_string(); - let generated_at = input.generated_at.trim().to_string(); - let items = normalize_saved_items(input.items); - if title.is_empty() { - return Err("Iceberg title is required.".to_string()); - } - if keyword.is_empty() { - return Err("Iceberg keyword is required.".to_string()); - } - if model.is_empty() { - return Err("Iceberg model is required.".to_string()); - } - if generated_at.is_empty() { - return Err("Iceberg generation time is required.".to_string()); - } - if items.is_empty() { - return Err("Iceberg has no usable items to save.".to_string()); - } - let now = now(); - let iceberg = SavedIceberg { - iceberg: IcebergResult { - keyword, - model, - items, - generated_at, - }, - id: uuid(), - title, - icon: normalize_iceberg_icon(input.icon), - saved_at: now.clone(), - updated_at: now, - }; - let mut data = load_icebergs(&state.paths.icebergs_path).await?; - data.icebergs.insert(0, iceberg.clone()); - save_json(&state.paths.icebergs_path, &data).await?; - Ok(iceberg) -} - -#[tauri::command] -async fn aether_crystallizer_reorder_saved( - state: State<'_, Backend>, - ids: Vec, -) -> Cmd> { - let mut data = load_icebergs(&state.paths.icebergs_path).await?; - data.icebergs = reorder(data.icebergs, &ids, |iceberg| &iceberg.id); - let summaries = data.icebergs.iter().map(saved_iceberg_summary).collect(); - save_json(&state.paths.icebergs_path, &data).await?; - Ok(summaries) -} - -#[tauri::command(rename_all = "camelCase")] -async fn aether_crystallizer_delete_saved(state: State<'_, Backend>, id: String) -> Cmd<()> { - let mut data = load_icebergs(&state.paths.icebergs_path).await?; - data.icebergs.retain(|iceberg| iceberg.id != id); - save_json(&state.paths.icebergs_path, &data).await -} - -#[tauri::command] -async fn aether_system_status(state: State<'_, Backend>) -> Cmd { - system_status(&state).await -} - -#[tauri::command] -async fn aether_system_settings(state: State<'_, Backend>) -> Cmd { - let settings = load_settings(&state.paths.settings_path).await?; - Ok(AppSettings { - browser: settings.browser, - developer_mode: settings.developer_mode, - updates: settings.updates, - }) -} - -#[tauri::command] -async fn aether_system_update_settings( - state: State<'_, Backend>, - input: UpdateSettingsInput, -) -> Cmd { - let mut settings = load_settings(&state.paths.settings_path).await?; - if let Some(browser) = input.browser { - if let Some(default_search_engine) = browser.default_search_engine { - settings.browser.default_search_engine = - normalize_search_engine_id(&default_search_engine); - } - } - if let Some(developer_mode) = input.developer_mode { - settings.developer_mode = developer_mode; - } - if let Some(updates) = input.updates { - if let Some(auto_check) = updates.auto_check { - settings.updates.auto_check = auto_check; - } - } - save_json(&state.paths.settings_path, &settings).await?; - Ok(AppSettings { - browser: settings.browser, - developer_mode: settings.developer_mode, - updates: settings.updates, - }) -} - -#[tauri::command] -async fn aether_system_check_for_update(state: State<'_, Backend>) -> Cmd { - let checked_at = now(); - let current_version = env!("CARGO_PKG_VERSION").to_string(); - - let request = state - .client - .get(AETHER_RELEASES_API_URL) - .header("Accept", "application/vnd.github+json") - .header("X-GitHub-Api-Version", "2022-11-28") - .header("User-Agent", "AETHER-update-checker"); - let response = match request.send().await { - Ok(response) => response, - Err(error) => { - persist_update_last_checked_at(&state.paths, &checked_at).await?; - return Ok(UpdateCheckResult { - current_version, - checked_at, - update_available: false, - latest_version: None, - latest_name: None, - release_url: None, - release_notes: None, - published_at: None, - error: Some(format!("Could not reach GitHub Releases: {error}")), - }); - } - }; - - if response.status().as_u16() == 404 { - persist_update_last_checked_at(&state.paths, &checked_at).await?; - return Ok(UpdateCheckResult { - current_version, - checked_at, - update_available: false, - latest_version: None, - latest_name: None, - release_url: None, - release_notes: None, - published_at: None, - error: Some("No published GitHub release found yet.".to_string()), - }); - } - - if !response.status().is_success() { - persist_update_last_checked_at(&state.paths, &checked_at).await?; - return Ok(UpdateCheckResult { - current_version, - checked_at, - update_available: false, - latest_version: None, - latest_name: None, - release_url: None, - release_notes: None, - published_at: None, - error: Some(format!( - "GitHub Releases returned HTTP {}.", - response.status().as_u16() - )), - }); - } - - let release = match response.json::().await { - Ok(release) => release, - Err(error) => { - persist_update_last_checked_at(&state.paths, &checked_at).await?; - return Ok(UpdateCheckResult { - current_version, - checked_at, - update_available: false, - latest_version: None, - latest_name: None, - release_url: None, - release_notes: None, - published_at: None, - error: Some(format!("Could not read GitHub release metadata: {error}")), - }); - } - }; - let latest_version = release_version_from_tag(&release.tag_name); - let update_available = version_is_newer(&latest_version, ¤t_version); - persist_update_last_checked_at(&state.paths, &checked_at).await?; - - Ok(UpdateCheckResult { - current_version, - checked_at, - update_available, - latest_version: Some(latest_version), - latest_name: release.name.or_else(|| Some(release.tag_name)), - release_url: Some(release.html_url), - release_notes: release - .body - .map(|body| body.trim().chars().take(1400).collect::()) - .filter(|body| !body.is_empty()), - published_at: release.published_at, - error: None, - }) -} - -#[tauri::command] -async fn aether_system_update_models( - state: State<'_, Backend>, - input: UpdateModelsInput, -) -> Cmd { - let mut settings = load_settings(&state.paths.settings_path).await?; - if let Some(model) = input.embedding_model { - settings.local_model.embedding_model = - Some(model.trim().to_string()).filter(|item| !item.is_empty()); - } - if let Some(model) = input.chat_model { - settings.local_model.chat_model = - Some(model.trim().to_string()).filter(|item| !item.is_empty()); - } - save_json(&state.paths.settings_path, &settings).await?; - system_status(&state).await -} - -#[tauri::command] -async fn aether_system_download_models( - app: AppHandle, - state: State<'_, Backend>, - input: DownloadModelsInput, -) -> Cmd { - download_managed_models(&app, &state, input).await?; - system_status(&state).await -} - -// ÆTHER keeps no cloud copy, so a user-owned export is the only real backup. This -// snapshots every store into one timestamped folder the user can copy anywhere. -#[tauri::command] -async fn aether_system_export_library( - app: AppHandle, - state: State<'_, Backend>, -) -> Cmd { - let paths = &state.paths; - let exported_at = now(); - let folder_name = format!("aether-export-{}", exported_at.replace(':', "-")); - let target_dir = paths.exports_path.join(&folder_name); - tokio::fs::create_dir_all(&target_dir) - .await - .map_err(|error| error.to_string())?; - - // chunks.json holds only metadata; without the binary sidecar beside it the - // export would restore a library whose sources cannot be searched. - // - // `chunks.v1.json` is deliberately not exported. It holds only vectors from a - // superseded embedding model, and the text they belong to is already in - // chunks.json — so a restore plus a re-index reproduces everything it contains, - // for none of the ~5x size. - let chunks_vec_path = vector_data_path(&paths.chunks_path); - let sources: [(&PathBuf, &str); 7] = [ - (&paths.library_path, "library.json"), - (&paths.chunks_path, "chunks.json"), - (&chunks_vec_path, "chunks.vec"), - (&paths.settings_path, "settings.json"), - (&paths.icebergs_path, "icebergs.json"), - (&paths.conversations_path, "conversations.json"), - (&paths.session_path, "session.json"), - ]; - - let mut files = Vec::new(); - let mut byte_size = 0_u64; - for (source, name) in sources { - if !tokio::fs::try_exists(source).await.unwrap_or(false) { - continue; - } - let copied = tokio::fs::copy(source, target_dir.join(name)) - .await - .map_err(|error| format!("Could not export {name}: {error}"))?; - byte_size += copied; - files.push(name.to_string()); - } - - if files.is_empty() { - // Leaving an empty folder behind would look like a successful export. - let _ = tokio::fs::remove_dir(&target_dir).await; - return Err("There is nothing to export yet.".to_string()); - } - - let library = load_library(&paths.library_path).await.unwrap_or_default(); - let capture_count = library.captures.len(); - let chunk_count = library - .collections - .iter() - .map(|collection| collection.chunk_count) - .sum::(); - - let manifest = serde_json::json!({ - "app": "aether", - "appVersion": app.package_info().version.to_string(), - "exportedAt": exported_at, - "files": files, - "captureCount": capture_count, - "chunkCount": chunk_count, - "collectionCount": library.collections.len(), - }); - let manifest_raw = - serde_json::to_string_pretty(&manifest).map_err(|error| error.to_string())?; - tokio::fs::write(target_dir.join("manifest.json"), format!("{manifest_raw}\n")) - .await - .map_err(|error| error.to_string())?; - files.push("manifest.json".to_string()); - - // Opening the folder is the expected payoff of an explicit export click, but a - // desktop without a file manager should not fail the export. - if let Err(error) = app.opener().reveal_item_in_dir(&target_dir) { - eprintln!("aether: exported but could not reveal folder: {error}"); - } - - Ok(LibraryExportResult { - path: target_dir.display().to_string(), - exported_at, - files, - capture_count, - chunk_count, - byte_size, - }) -} - -// Deliberately a separate command rather than a field on SystemStatus: answering it -// forces the vector store to load, and SystemStatus runs at startup where that would -// add a full parse of the metadata plus the sidecar to launch. Settings asks for this -// only when opened, which is also the only place the re-index button lives. -#[tauri::command] -async fn aether_library_index_status(state: State<'_, Backend>) -> Cmd { - with_vectors_read(&state, |vectors| LibraryIndexStatus { - dim: vectors.dim, - embedded: vectors.embedded_count() as usize, - pending_reembed: vectors.pending_reembed_count(), - }) - .await -} - -// Re-embeds every retained chunk with the loaded embedding model. This is the only -// way out of a store whose vectors came from a different model: the widths cannot be -// compared, so those chunks are invisible to search until they are embedded again. -// Chunk text is kept in the store, so this is local compute — no page is refetched. -#[tauri::command] -async fn aether_library_reindex( - app: AppHandle, - state: State<'_, Backend>, -) -> Cmd { - let settings = load_settings(&state.paths.settings_path).await?; - - // Snapshot ids and text without holding the lock: embedding takes minutes on a - // large library, and blocking every capture for its duration is not acceptable. - let pending = with_vectors_read(&state, |vectors| { - vectors - .chunks - .iter() - .map(|chunk| (chunk.id.clone(), chunk.text.clone())) - .collect::>() - }) - .await?; - - if pending.is_empty() { - return Err("There is nothing to re-index yet.".to_string()); - } - - let total = pending.len(); - emit_capture_progress(&app, "Re-indexing library", Some(0), Some(total)); - - let mut vectors_by_id: HashMap> = HashMap::with_capacity(total); - // Batched so progress advances steadily and peak memory stays bounded, rather than - // handing the runtime every chunk in the library at once. - for (batch_index, batch) in pending.chunks(REINDEX_BATCH_SIZE).enumerate() { - let inputs = batch - .iter() - .map(|(_, text)| text.clone()) - .collect::>(); - let embeddings = local_embed_with_progress( - &state, - &settings, - inputs, - Some(EmbeddingProgress { - app: app.clone(), - message: "Re-indexing library".to_string(), - }), - ) - .await?; - if embeddings.len() != batch.len() { - return Err( - "Local embedding model returned an unexpected number of embeddings.".to_string(), - ); - } - for ((id, _), vector) in batch.iter().zip(embeddings) { - vectors_by_id.insert(id.clone(), vector); - } - emit_capture_progress( - &app, - "Re-indexing library", - Some(((batch_index + 1) * REINDEX_BATCH_SIZE).min(total)), - Some(total), - ); - } - - let dim = vectors_by_id - .values() - .map(Vec::len) - .max() - .filter(|dim| *dim > 0) - .ok_or_else(|| "The embedding model returned no usable vectors.".to_string())?; - - let mut guard = state.vectors.write().await; - if guard.is_none() { - *guard = Some(load_vectors(&state.paths.chunks_path).await?); - } - let data = guard.as_mut().expect("vector store cache"); - - // Rebuild rather than patch: the store's width is changing, so every slot has to - // be reassigned against the new stride. - data.dim = dim; - data.next_slot = 0; - let existing = std::mem::take(&mut data.chunks); - // Matched by id, so chunks captured while the embedding ran keep their own vectors - // instead of being matched to the wrong text by position. - data.push_chunks(existing.into_iter().map(|mut chunk| { - if let Some(vector) = vectors_by_id.remove(&chunk.id) { - chunk.vector = vector; - } - chunk.needs_reembed = false; - chunk - })); - - let embedded = data.embedded_count() as usize; - let still_pending = data.pending_reembed_count(); - write_vector_sidecar(&state.paths.chunks_path, data, 0).await?; - save_vector_metadata(&state.paths.chunks_path, data).await?; - drop(guard); - - emit_capture_progress(&app, "Re-index complete", Some(total), Some(total)); - Ok(LibraryReindexResult { - embedded, - still_pending, - dim, - reindexed_at: now(), - }) -} - -#[tauri::command(rename_all = "camelCase")] -fn aether_system_open_external_url(app: AppHandle, url: String) -> Cmd<()> { - let parsed = Url::parse(url.trim()).map_err(|_| "Invalid release URL.".to_string())?; - match parsed.scheme() { - "https" | "http" => app - .opener() - .open_url(parsed.to_string(), None::) - .map_err(|error| error.to_string()), - _ => Err("Only web URLs can be opened from Settings.".to_string()), - } -} - -#[tauri::command(rename_all = "camelCase")] -fn aether_layout_set_panel_collapsed( - app: AppHandle, - state: State, - collapsed: bool, -) -> Cmd<()> { - { - let mut tabs = lock_tabs(&state)?; - tabs.panel_collapsed = collapsed; - } - sync_native_webview_visibility(&app, &state)?; - emit_state(&app, &state) -} - -#[tauri::command(rename_all = "camelCase")] -fn aether_layout_set_modal_overlay_open( - app: AppHandle, - state: State, - open: bool, -) -> Cmd<()> { - { - let mut tabs = lock_tabs(&state)?; - tabs.modal_overlay_open = open; - } - sync_native_webview_visibility(&app, &state) -} - -#[tauri::command] -fn aether_layout_show_status_toast(input: StatusToastInput) -> Cmd<()> { - let _ = (input.message, input.tone, input.duration_ms); - Ok(()) -} - -fn emit_capture_progress( - app: &AppHandle, - message: impl Into, - current: Option, - total: Option, -) { - let _ = app.emit( - "aether:capture-progress", - CaptureProgress { - message: message.into(), - current, - total, - }, - ); -} - -async fn navigate_active_tab(app: &AppHandle, state: &State<'_, Backend>, url: &str) -> Cmd<()> { - let settings = load_settings(&state.paths.settings_path).await?; - let (tab_id, target_url) = { - let mut tabs = lock_tabs(state)?; - let tab = tabs - .active_tab_mut() - .ok_or_else(|| "No active browser tab.".to_string())?; - tab.navigate(url, &settings.browser.default_search_engine); - let result = (tab.id.clone(), tab.url.clone()); - tabs.dashboard_open = false; - result - }; - navigate_native_webview(app, state, &tab_id, &target_url)?; - emit_state(app, state) -} - -async fn search_collection( - state: &State<'_, Backend>, - input: SearchCollectionInput, -) -> Cmd> { - let query = input.query.trim().to_string(); - if query.is_empty() { - return Ok(Vec::new()); - } - get_collection(&state.paths.library_path, &input.collection_id).await?; - let settings = load_settings(&state.paths.settings_path).await?; - let query_vector = local_embed_query(state, &settings, query).await?; - with_vectors_read(state, |vectors| { - let mut scored = vectors - .chunks - .iter() - .filter(|chunk| chunk.collection_id == input.collection_id) - .map(|chunk| (cosine_distance(&query_vector, &chunk.vector), chunk)) - .collect::>(); - scored.sort_by(|left, right| left.0.partial_cmp(&right.0).unwrap_or(Ordering::Equal)); - scored.truncate(input.limit.unwrap_or(8)); - scored - .into_iter() - .map(|(score, chunk)| SearchResult { - score, - id: chunk.id.clone(), - collection_id: chunk.collection_id.clone(), - capture_id: chunk.capture_id.clone(), - app_id: chunk.app_id.clone(), - title: chunk.title.clone(), - url: chunk.url.clone(), - captured_at: chunk.captured_at.clone(), - chunk_index: chunk.chunk_index, - text: chunk.text.clone(), - }) - .collect::>() - }) - .await -} - -// Library search groups by capture, not by chunk. The chunk-level results that -// power retrieval are the wrong shape for a person: eight hits from one long page -// reads as eight sources. One row per source, with its best-matching passage and a -// count of how many passages matched, is what someone scanning results wants. -async fn search_library( - state: &State<'_, Backend>, - input: SearchLibraryInput, -) -> Cmd { - let query = input.query.trim().to_string(); - if query.is_empty() { - return Ok(LibrarySearchResult { - query, - hits: Vec::new(), - mode: "semantic".to_string(), - searched_chunks: 0, - }); - } - - let library = load_library(&state.paths.library_path).await?; - let collection_names = library - .collections - .iter() - .map(|collection| (collection.id.clone(), collection.name.clone())) - .collect::>(); - if let Some(collection_id) = input.collection_id.as_deref() { - get_collection(&state.paths.library_path, collection_id).await?; - } - - let settings = load_settings(&state.paths.settings_path).await?; - let limit = input.limit.unwrap_or(20).clamp(1, 60); - - // Without an embedding model there is nothing to compare vectors against, so - // fall back to literal matching rather than failing. Search staying usable with - // no models installed is the difference between a browsable library and a - // library you can only guess at. - let query_vector = local_embed_query(state, &settings, query.clone()).await.ok(); - let mode = if query_vector.is_some() { - "semantic" - } else { - "literal" - }; - let needle = query.to_lowercase(); - - let scope = input.collection_id.clone(); - let (hits, searched_chunks) = with_vectors_read(state, |vectors| { - rank_library_hits( - &vectors.chunks, - &collection_names, - scope.as_deref(), - query_vector.as_deref(), - &needle, - limit, - ) - }) - .await?; - - Ok(LibrarySearchResult { - query, - hits, - mode: mode.to_string(), - searched_chunks, - }) -} - -// Pure ranking core, split out from search_library so the retrieval contract can be -// tested without a Tauri app or a 640 MB embedding model. Everything model-independent -// about search quality — scoping, per-capture grouping, ordering, limits — lives here. -fn rank_library_hits( - chunks: &[ChunkRecord], - collection_names: &HashMap, - scope: Option<&str>, - query_vector: Option<&[f32]>, - needle: &str, - limit: usize, -) -> (Vec, usize) { - // map_or rather than is_none_or: the latter needs Rust 1.82 and this crate - // declares MSRV 1.77.2. - let scoped = chunks - .iter() - .filter(|chunk| scope.map_or(true, |id| chunk.collection_id == id)); - - let mut best: HashMap = HashMap::new(); - let mut examined = 0_usize; - - for chunk in scoped { - examined += 1; - let score = match query_vector { - Some(vector) => semantic_score_from_distance(cosine_distance(vector, &chunk.vector)), - None => literal_match_score(needle, chunk), - }; - if score <= 0.0 { - continue; - } - - match best.get_mut(&chunk.capture_id) { - Some(existing) => { - existing.chunk_matches += 1; - // One row per source shows its *best* passage, so a later weaker - // chunk must not overwrite a stronger earlier one. - if score > existing.score { - existing.score = score; - existing.excerpt = semantic_trail_excerpt(&chunk.text, 240); - } - } - None => { - best.insert( - chunk.capture_id.clone(), - LibrarySearchHit { - capture_id: chunk.capture_id.clone(), - collection_id: chunk.collection_id.clone(), - collection_name: collection_names - .get(&chunk.collection_id) - .cloned() - .unwrap_or_else(|| "Unknown hub".to_string()), - title: chunk.title.clone(), - url: chunk.url.clone(), - host: get_tab_host(&chunk.url), - captured_at: chunk.captured_at.clone(), - excerpt: semantic_trail_excerpt(&chunk.text, 240), - score, - chunk_matches: 1, - }, - ); - } - } - } - - let mut hits = best.into_values().collect::>(); - hits.sort_by(|left, right| { - right - .score - .partial_cmp(&left.score) - .unwrap_or(Ordering::Equal) - .then_with(|| right.captured_at.cmp(&left.captured_at)) - // Final tiebreak keeps output stable: HashMap iteration order is not. - .then_with(|| left.capture_id.cmp(&right.capture_id)) - }); - hits.truncate(limit); - (hits, examined) -} - -// Literal scoring for the no-embedding-model path. Title and host matches outrank -// body matches, because someone typing a remembered name wants that page first. -fn literal_match_score(needle: &str, chunk: &ChunkRecord) -> f64 { - if needle.is_empty() { - return 0.0; - } - let mut score = 0.0_f64; - if chunk.title.to_lowercase().contains(needle) { - score += 70.0; - } - if chunk.url.to_lowercase().contains(needle) { - score += 20.0; - } - if chunk.text.to_lowercase().contains(needle) { - score += 25.0; - } - score.min(100.0) -} - -#[tauri::command] -async fn aether_search_library( - state: State<'_, Backend>, - input: SearchLibraryInput, -) -> Cmd { - search_library(&state, input).await -} - -#[derive(Clone)] -struct SemanticTrailChunkCandidate { - chunk: ChunkRecord, - collection_name: String, - score: SemanticTrailScoreBreakdown, - reasons: Vec, -} - -#[derive(Clone)] -struct FlowSourceCandidate { - capture: CaptureSummary, - collection_name: String, - vector: Vec, - excerpt: String, -} - -struct FlowEdgeCandidate { - from: String, - to: String, - weight: f64, -} - -async fn semantic_trail_generate( - state: &State<'_, Backend>, - input: SemanticTrailInput, -) -> Cmd { - let limit = input - .limit - .unwrap_or(DEFAULT_SEMANTIC_TRAIL_LIMIT) - .clamp(1, MAX_SEMANTIC_TRAIL_LIMIT); - let explicit_query = input - .query - .as_deref() - .map(str::trim) - .filter(|query| !query.is_empty()); - let (root, visible_query, embedding_query, root_url_key) = - if let Some(query) = explicit_query { - ( - SemanticTrailRoot { - title: query.to_string(), - url: String::new(), - host: String::new(), - excerpt: "Custom Focus lens matching captured sources across your knowledge hubs." - .to_string(), - }, - query.to_string(), - query.to_string(), - None, - ) - } else { - let active_tab = { - let tabs = lock_tabs(state)?; - if tabs.dashboard_open { - return Err("Open a web page before building Flow.".to_string()); - } - tabs.active_tab() - .cloned() - .ok_or_else(|| "No active browser tab.".to_string())? - }; - let captured = extract_readable_active_page(state, &active_tab).await?; - let root_host = get_tab_host(&captured.url); - let root_url_key = normalize_capture_url_key(&captured.url); - ( - SemanticTrailRoot { - title: captured.title.clone(), - url: captured.url.clone(), - host: root_host, - excerpt: semantic_trail_excerpt(&captured.text, 420), - }, - captured.title.clone(), - semantic_trail_default_query(&captured), - Some(root_url_key), - ) - }; - - let library = load_library(&state.paths.library_path).await?; - let collection_names = library - .collections - .iter() - .map(|collection| (collection.id.clone(), collection.name.clone())) - .collect::>(); - let root_collection_ids = root_url_key - .as_deref() - .map(|key| { - library - .captures - .iter() - .filter(|capture| normalize_capture_url_key(&capture.url) == key) - .map(|capture| capture.collection_id.clone()) - .collect::>() - }) - .unwrap_or_default(); - let chunks = with_vectors_read(state, |vectors| vectors.chunks.clone()).await?; - - if chunks.is_empty() { - return Ok(SemanticTrailResult { - query: visible_query, - generated_at: now(), - root, - items: Vec::new(), - edges: Vec::new(), - }); - } - - let settings = load_settings(&state.paths.settings_path).await?; - let query_vector = local_embed_query(state, &settings, embedding_query).await?; - - let mut candidates = chunks - .into_iter() - .filter_map(|chunk| { - let distance = cosine_distance(&query_vector, &chunk.vector); - if !distance.is_finite() { - return None; - } - let same_collection = root_collection_ids.contains(&chunk.collection_id); - let score = semantic_trail_score_breakdown(distance, &chunk.captured_at); - if score.semantic < SEMANTIC_TRAIL_MIN_SCORE { - return None; - } - let reasons = semantic_trail_reasons(&score, same_collection); - let collection_name = collection_names - .get(&chunk.collection_id) - .cloned() - .unwrap_or_else(|| "Knowledge Hub".to_string()); - Some(SemanticTrailChunkCandidate { - chunk, - collection_name, - score, - reasons, - }) - }) - .collect::>(); - - candidates.sort_by(|left, right| { - right - .score - .total - .partial_cmp(&left.score.total) - .unwrap_or(Ordering::Equal) - .then_with(|| { - right - .score - .semantic - .partial_cmp(&left.score.semantic) - .unwrap_or(Ordering::Equal) - }) - }); - - let items = semantic_trail_items(candidates, limit); - let edges = semantic_trail_edges(&root, &items); - - Ok(SemanticTrailResult { - query: visible_query, - generated_at: now(), - root, - items, - edges, - }) -} - -// Rank the user's hubs against the active page so capture can silently pre-select the best -// home for it. Best-effort: any reason we cannot produce a confident match returns Ok(None) -// rather than an error, so a failed suggestion never blocks or interrupts capturing. -async fn suggest_capture_hub( - state: &State<'_, Backend>, -) -> Cmd> { - let active_tab = { - let tabs = lock_tabs(state)?; - if tabs.dashboard_open { - return Ok(None); - } - match tabs.active_tab().cloned() { - Some(tab) => tab, - None => return Ok(None), - } - }; - - let captured = match extract_readable_active_page(state, &active_tab).await { - Ok(page) => page, - Err(_) => return Ok(None), - }; - - let library = load_library(&state.paths.library_path).await?; - if library.collections.is_empty() { - return Ok(None); - } - let chunks = with_vectors_read(state, |vectors| vectors.chunks.clone()).await?; - if chunks.is_empty() { - return Ok(None); - } - - let settings = load_settings(&state.paths.settings_path).await?; - let embedding_query = semantic_trail_default_query(&captured); - let query_vector = match local_embed_query(state, &settings, embedding_query).await { - Ok(vector) => vector, - Err(_) => return Ok(None), - }; - - // A hub is a strong home for this page if it already holds a source whose meaning is - // close to it, so score each hub by its single closest chunk. - let mut best_by_collection: HashMap = HashMap::new(); - for chunk in &chunks { - let distance = cosine_distance(&query_vector, &chunk.vector); - if !distance.is_finite() { - continue; - } - let semantic = semantic_score_from_distance(distance); - let entry = best_by_collection - .entry(chunk.collection_id.clone()) - .or_insert((0.0, String::new())); - if semantic > entry.0 { - entry.0 = semantic; - entry.1 = chunk.title.clone(); - } - } - - let Some((collection_id, (confidence, sample_title))) = - best_by_collection.into_iter().max_by(|left, right| { - left.1.0.partial_cmp(&right.1.0).unwrap_or(Ordering::Equal) - }) - else { - return Ok(None); - }; - - if confidence < CAPTURE_SUGGEST_MIN_SCORE { - return Ok(None); - } - - let collection_name = library - .collections - .iter() - .find(|collection| collection.id == collection_id) - .map(|collection| collection.name.clone()) - .unwrap_or_else(|| "Knowledge Hub".to_string()); - - Ok(Some(CaptureHubSuggestion { - collection_id, - collection_name, - confidence: round_score(confidence), - sample_title, - })) -} - -async fn flow_graph_generate( - state: &State<'_, Backend>, - input: FlowGraphInput, -) -> Cmd { - let query = input.query.unwrap_or_default().trim().to_string(); - let source_limit = input - .source_limit - .unwrap_or(DEFAULT_FLOW_GRAPH_SOURCE_LIMIT) - .clamp(1, MAX_FLOW_GRAPH_SOURCE_LIMIT); - let library = load_library(&state.paths.library_path).await?; - let collection_names = library - .collections - .iter() - .map(|collection| (collection.id.clone(), collection.name.clone())) - .collect::>(); - let chunks = with_vectors_read(state, |vectors| vectors.chunks.clone()).await?; - let mut chunks_by_capture = HashMap::>::new(); - for chunk in chunks { - chunks_by_capture - .entry(chunk.capture_id.clone()) - .or_default() - .push(chunk); - } - - let mut captures = library.captures.clone(); - captures.sort_by(|left, right| right.captured_at.cmp(&left.captured_at)); - let indexed_source_count = captures - .iter() - .filter(|capture| chunks_by_capture.contains_key(&capture.id)) - .count(); - let mut sources = Vec::::new(); - for capture in captures { - if sources.len() >= source_limit { - break; - } - let Some(chunks) = chunks_by_capture.get(&capture.id) else { - continue; - }; - let Some(vector) = average_flow_source_vector(chunks) else { - continue; - }; - let collection_name = collection_names - .get(&capture.collection_id) - .cloned() - .unwrap_or_else(|| "Knowledge Hub".to_string()); - let excerpt = chunks - .iter() - .max_by_key(|chunk| chunk.text.chars().count()) - .map(|chunk| semantic_trail_excerpt(&chunk.text, 300)) - .unwrap_or_default(); - sources.push(FlowSourceCandidate { - capture, - collection_name, - vector, - excerpt, - }); - } - - let query_scores = if query.is_empty() || sources.is_empty() { - HashMap::new() - } else { - let settings = load_settings(&state.paths.settings_path).await?; - let query_vector = local_embed_query(state, &settings, query.clone()).await?; - sources - .iter() - .map(|source| { - let distance = cosine_distance(&query_vector, &source.vector); - ( - flow_source_node_id(&source.capture.id), - semantic_score_from_distance(distance), - ) - }) - .filter(|(_, score)| score.is_finite()) - .collect::>() - }; - - let mut nodes = Vec::::new(); - if !query.is_empty() { - nodes.push(FlowGraphNode { - id: "query".to_string(), - kind: FlowGraphNodeKind::Query, - title: query.clone(), - subtitle: "Semantic search lens".to_string(), - weight: 72.0, - collection_id: None, - collection_name: None, - capture_id: None, - url: None, - host: None, - captured_at: None, - excerpt: None, - score: None, - }); - } - - for collection in &library.collections { - nodes.push(FlowGraphNode { - id: flow_hub_node_id(&collection.id), - kind: FlowGraphNodeKind::Hub, - title: collection.name.clone(), - subtitle: format!( - "{} sources · {} chunks", - collection.capture_count, collection.chunk_count - ), - weight: (42.0 + (collection.capture_count as f64 * 7.0)).min(92.0), - collection_id: Some(collection.id.clone()), - collection_name: Some(collection.name.clone()), - capture_id: None, - url: None, - host: None, - captured_at: Some(collection.updated_at.clone()), - excerpt: Some(collection.description.clone()) - .filter(|description| !description.is_empty()), - score: None, - }); - } - - for source in &sources { - let node_id = flow_source_node_id(&source.capture.id); - nodes.push(FlowGraphNode { - id: node_id.clone(), - kind: FlowGraphNodeKind::Source, - title: source.capture.title.clone(), - subtitle: source.collection_name.clone(), - weight: (24.0 + (source.capture.chunk_count as f64 * 2.0)).min(58.0), - collection_id: Some(source.capture.collection_id.clone()), - collection_name: Some(source.collection_name.clone()), - capture_id: Some(source.capture.id.clone()), - url: Some(source.capture.url.clone()), - host: Some(get_tab_host(&source.capture.url)), - captured_at: Some(source.capture.captured_at.clone()), - excerpt: Some(source.excerpt.clone()).filter(|excerpt| !excerpt.is_empty()), - score: query_scores.get(&node_id).copied().map(round_score), - }); - } - - let mut edges = Vec::::new(); - for source in &sources { - push_flow_graph_edge( - &mut edges, - &flow_hub_node_id(&source.capture.collection_id), - &flow_source_node_id(&source.capture.id), - FlowGraphEdgeKind::Contains, - 36.0, - ); - } - - if !query_scores.is_empty() { - let mut matches = query_scores.iter().collect::>(); - matches.sort_by(|left, right| right.1.partial_cmp(left.1).unwrap_or(Ordering::Equal)); - for (node_id, score) in matches.into_iter().take(FLOW_GRAPH_QUERY_MATCH_LIMIT) { - if *score >= FLOW_GRAPH_MIN_EDGE_SCORE { - push_flow_graph_edge( - &mut edges, - "query", - node_id, - FlowGraphEdgeKind::QueryMatch, - *score, - ); - } - } - } - - let mut semantic_edges = Vec::::new(); - for left_index in 0..sources.len() { - for right_index in (left_index + 1)..sources.len() { - let left = &sources[left_index]; - let right = &sources[right_index]; - let distance = cosine_distance(&left.vector, &right.vector); - let weight = semantic_score_from_distance(distance); - if weight >= FLOW_GRAPH_MIN_EDGE_SCORE { - semantic_edges.push(FlowEdgeCandidate { - from: flow_source_node_id(&left.capture.id), - to: flow_source_node_id(&right.capture.id), - weight, - }); - } - } - } - semantic_edges.sort_by(|left, right| { - right - .weight - .partial_cmp(&left.weight) - .unwrap_or(Ordering::Equal) - }); - let mut neighbor_counts = HashMap::::new(); - let mut semantic_edge_count = 0_usize; - for candidate in semantic_edges { - if semantic_edge_count >= FLOW_GRAPH_MAX_SEMANTIC_EDGES { - break; - } - let left_count = *neighbor_counts.get(&candidate.from).unwrap_or(&0); - let right_count = *neighbor_counts.get(&candidate.to).unwrap_or(&0); - if left_count >= FLOW_GRAPH_NEIGHBORS_PER_SOURCE - || right_count >= FLOW_GRAPH_NEIGHBORS_PER_SOURCE - { - continue; - } - push_flow_graph_edge( - &mut edges, - &candidate.from, - &candidate.to, - FlowGraphEdgeKind::Semantic, - candidate.weight, - ); - semantic_edge_count += 1; - *neighbor_counts.entry(candidate.from).or_insert(0) += 1; - *neighbor_counts.entry(candidate.to).or_insert(0) += 1; - } - - Ok(FlowGraphResult { - query, - generated_at: now(), - nodes, - edges, - hub_count: library.collections.len(), - source_count: sources.len(), - omitted_source_count: indexed_source_count.saturating_sub(sources.len()), - }) -} - -fn average_flow_source_vector(chunks: &[ChunkRecord]) -> Option> { - let first = chunks.first()?; - let dimensions = first.vector.len(); - if dimensions == 0 { - return None; - } - let mut total = vec![0.0_f32; dimensions]; - let mut count = 0.0_f32; - for chunk in chunks { - if chunk.vector.len() != dimensions { - continue; - } - for (index, value) in chunk.vector.iter().enumerate() { - total[index] += *value; - } - count += 1.0; - } - if count == 0.0 { - return None; - } - for value in &mut total { - *value /= count; - } - Some(normalize_embedding(&total)) -} - -fn flow_hub_node_id(collection_id: &str) -> String { - format!("hub-{collection_id}") -} - -fn flow_source_node_id(capture_id: &str) -> String { - format!("source-{capture_id}") -} - -fn push_flow_graph_edge( - edges: &mut Vec, - from: &str, - to: &str, - kind: FlowGraphEdgeKind, - weight: f64, -) { - if from == to { - return; - } - edges.push(FlowGraphEdge { - id: format!("{}-{from}-{to}", flow_edge_kind_label(kind)), - from: from.to_string(), - to: to.to_string(), - kind, - weight: round_score(weight), - }); -} - -fn flow_edge_kind_label(kind: FlowGraphEdgeKind) -> &'static str { - match kind { - FlowGraphEdgeKind::Contains => "contains", - FlowGraphEdgeKind::Semantic => "semantic", - FlowGraphEdgeKind::QueryMatch => "query", - } -} - -async fn air_prepare_dossier( - state: &State<'_, Backend>, - input: AirDossierInput, - synthesize: bool, -) -> Cmd { - let lens_kind = input.lens_kind.unwrap_or_default(); - let lens = input.lens.trim().to_string(); - let visible_lens = if lens.is_empty() { - match lens_kind { - AirLensKind::Topic => "Local knowledge".to_string(), - AirLensKind::Flow => "Current Flow lens".to_string(), - AirLensKind::Hub => "Selected knowledge hub".to_string(), - AirLensKind::Answer => "Latest AiON answer".to_string(), - AirLensKind::Iceberg => "Saved iCE map".to_string(), - } - } else { - lens - }; - let generated_at = now(); - let limit = input.limit.unwrap_or(12).clamp(1, 24); - let (sources, seed_answer, ice_map, seed_model) = - air_gather_context(state, &input, lens_kind, &visible_lens, limit).await?; - let title = format!("AiR Dossier: {visible_lens}"); - let output_dir = resolve_air_export_dir(&state.paths, false).await?; - - let mut model = seed_model.unwrap_or_else(|| "deterministic-scaffold".to_string()); - let synthesized_sections = if synthesize { - match air_synthesize_sections(state, &visible_lens, lens_kind, &sources, seed_answer.as_deref(), ice_map.as_deref()).await { - Ok((synthesis_model, sections)) => { - model = synthesis_model; - Some(sections) - } - Err(_) => None, - } - } else { - None - }; - - let markdown_preview = build_air_markdown(AirMarkdownInput { - title: &title, - lens: &visible_lens, - lens_kind, - generated_at: &generated_at, - model: &model, - sources: &sources, - synthesized_sections: synthesized_sections.as_deref(), - seed_answer: seed_answer.as_deref(), - ice_map: ice_map.as_deref(), - }); - - Ok(AirPreparedDossier { - title, - lens: visible_lens, - lens_kind, - generated_at, - model: Some(model), - output_dir: output_dir.display().to_string(), - markdown_preview, - sources, - }) -} - -async fn air_gather_context( - state: &State<'_, Backend>, - input: &AirDossierInput, - lens_kind: AirLensKind, - lens: &str, - limit: usize, -) -> Cmd<(Vec, Option, Option, Option)> { - match lens_kind { - AirLensKind::Answer => { - let Some(answer) = input.answer.clone() else { - return Ok((Vec::new(), None, None, None)); - }; - let library = load_library(&state.paths.library_path).await?; - let collection_names = library - .collections - .iter() - .map(|collection| (collection.id.clone(), collection.name.clone())) - .collect::>(); - let sources = search_results_to_air_sources( - dedupe_citations(answer.citations).into_iter().take(limit).collect(), - &collection_names, - ); - Ok((sources, Some(answer.answer), None, Some(answer.model))) - } - AirLensKind::Iceberg => { - let icebergs = load_icebergs(&state.paths.icebergs_path).await?; - let selected = input - .saved_iceberg_id - .as_deref() - .and_then(|id| icebergs.icebergs.iter().find(|iceberg| iceberg.id == id)) - .or_else(|| { - icebergs - .icebergs - .iter() - .find(|iceberg| iceberg.title.eq_ignore_ascii_case(lens)) - }) - .or_else(|| icebergs.icebergs.first()); - let Some(iceberg) = selected else { - return Ok((Vec::new(), None, None, None)); - }; - let mut items = iceberg.iceberg.items.clone(); - items.sort_by(|left, right| { - left.level - .cmp(&right.level) - .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase())) - }); - let sources = items - .iter() - .take(limit) - .map(|item| AirDossierSource { - id: format!("iceberg-{}", item.id), - title: item.name.clone(), - excerpt: format!("Level {}: {}", item.level, item.description), - collection_name: Some(format!("iCE · {}", iceberg.title)), - url: None, - host: None, - captured_at: Some(iceberg.updated_at.clone()), - score: None, - }) - .collect::>(); - let map = items - .iter() - .map(|item| format!("- Level {} · {}: {}", item.level, item.name, item.description)) - .collect::>() - .join("\n"); - Ok((sources, None, Some(map), Some(iceberg.iceberg.model.clone()))) - } - AirLensKind::Hub => { - let sources = air_sources_for_hub(state, input.collection_id.as_deref(), limit).await?; - Ok((sources, None, None, None)) - } - AirLensKind::Flow => { - if let Some(collection_id) = input.collection_id.as_deref() { - let sources = air_sources_for_hub(state, Some(collection_id), limit).await?; - return Ok((sources, None, None, None)); - } - let mut sources = match input.capture_id.as_deref() { - Some(capture_id) => air_sources_for_capture(state, capture_id).await?, - None => Vec::new(), - }; - let existing = sources - .iter() - .map(|source| source.id.clone()) - .collect::>(); - let related = air_sources_for_topic(state, lens, limit).await?; - sources.extend( - related - .into_iter() - .filter(|source| !existing.contains(&source.id)) - .take(limit.saturating_sub(sources.len())), - ); - Ok((sources, None, None, None)) - } - AirLensKind::Topic => { - let sources = air_sources_for_topic(state, lens, limit).await?; - Ok((sources, None, None, None)) - } - } -} - -async fn air_sources_for_hub( - state: &State<'_, Backend>, - collection_id: Option<&str>, - limit: usize, -) -> Cmd> { - let library = load_library(&state.paths.library_path).await?; - let collection_names = library - .collections - .iter() - .map(|collection| (collection.id.clone(), collection.name.clone())) - .collect::>(); - let vectors = with_vectors_read(state, |vectors| vectors.chunks.clone()).await?; - let mut chunks_by_capture = HashMap::>::new(); - for chunk in vectors { - chunks_by_capture - .entry(chunk.capture_id.clone()) - .or_default() - .push(chunk); - } - let mut captures = library - .captures - .into_iter() - .filter(|capture| { - collection_id - .map(|id| capture.collection_id == id) - .unwrap_or(true) - }) - .collect::>(); - captures.sort_by(|left, right| right.captured_at.cmp(&left.captured_at)); - Ok(captures - .into_iter() - .take(limit) - .map(|capture| { - let excerpt = chunks_by_capture - .get(&capture.id) - .and_then(|chunks| chunks.iter().max_by_key(|chunk| chunk.text.chars().count())) - .map(|chunk| semantic_trail_excerpt(&chunk.text, 700)) - .or_else(|| capture.metadata.as_ref().and_then(|metadata| metadata.summary.clone())) - .unwrap_or_default(); - AirDossierSource { - id: capture.id.clone(), - title: capture.title, - excerpt, - collection_name: collection_names.get(&capture.collection_id).cloned(), - url: Some(capture.url.clone()), - host: Some(get_tab_host(&capture.url)), - captured_at: Some(capture.captured_at), - score: None, - } - }) - .collect()) -} - -async fn air_sources_for_capture( - state: &State<'_, Backend>, - capture_id: &str, -) -> Cmd> { - let library = load_library(&state.paths.library_path).await?; - let collection_names = library - .collections - .iter() - .map(|collection| (collection.id.clone(), collection.name.clone())) - .collect::>(); - let Some(capture) = library - .captures - .iter() - .find(|capture| capture.id == capture_id) - .cloned() - else { - return Ok(Vec::new()); - }; - let mut chunks = with_vectors_read(state, |vectors| { - vectors - .chunks - .iter() - .filter(|chunk| chunk.capture_id == capture_id) - .cloned() - .collect::>() - }) - .await?; - chunks.sort_by_key(|chunk| chunk.chunk_index); - let excerpt = semantic_trail_excerpt( - &chunks - .iter() - .map(|chunk| chunk.text.as_str()) - .collect::>() - .join("\n\n"), - 1200, - ); - Ok(vec![AirDossierSource { - id: capture.id.clone(), - title: capture.title, - excerpt, - collection_name: collection_names.get(&capture.collection_id).cloned(), - url: Some(capture.url.clone()), - host: Some(get_tab_host(&capture.url)), - captured_at: Some(capture.captured_at), - score: Some(100.0), - }]) -} - -async fn air_sources_for_topic( - state: &State<'_, Backend>, - lens: &str, - limit: usize, -) -> Cmd> { - let library = load_library(&state.paths.library_path).await?; - let collection_names = library - .collections - .iter() - .map(|collection| (collection.id.clone(), collection.name.clone())) - .collect::>(); - let vectors = with_vectors_read(state, |vectors| vectors.chunks.clone()).await?; - if vectors.is_empty() { - return Ok(Vec::new()); - } - - let results = if lens.trim().is_empty() || lens == "Current Flow lens" { - let mut chunks = vectors; - chunks.sort_by(|left, right| right.captured_at.cmp(&left.captured_at)); - chunks - .into_iter() - .take(limit * 2) - .map(|chunk| SearchResult { - score: 0.0, - id: chunk.id, - collection_id: chunk.collection_id, - capture_id: chunk.capture_id, - app_id: chunk.app_id, - title: chunk.title, - url: chunk.url, - captured_at: chunk.captured_at, - chunk_index: chunk.chunk_index, - text: chunk.text, - }) - .collect::>() - } else { - let settings = load_settings(&state.paths.settings_path).await?; - let query_vector = local_embed_query(state, &settings, lens.to_string()).await?; - let mut scored = vectors - .into_iter() - .filter_map(|chunk| { - let distance = cosine_distance(&query_vector, &chunk.vector); - if !distance.is_finite() { - return None; - } - Some((distance, chunk)) - }) - .collect::>(); - scored.sort_by(|left, right| left.0.partial_cmp(&right.0).unwrap_or(Ordering::Equal)); - scored - .into_iter() - .take(limit * 3) - .map(|(score, chunk)| SearchResult { - score, - id: chunk.id, - collection_id: chunk.collection_id, - capture_id: chunk.capture_id, - app_id: chunk.app_id, - title: chunk.title, - url: chunk.url, - captured_at: chunk.captured_at, - chunk_index: chunk.chunk_index, - text: chunk.text, - }) - .collect::>() - }; - Ok(search_results_to_air_sources( - dedupe_citations(results).into_iter().take(limit).collect(), - &collection_names, - )) -} - -fn search_results_to_air_sources( - results: Vec, - collection_names: &HashMap, -) -> Vec { - results - .into_iter() - .map(|result| { - let collection_name = collection_names.get(&result.collection_id).cloned(); - AirDossierSource { - id: result.capture_id.clone(), - title: result.title, - excerpt: semantic_trail_excerpt(&result.text, 850), - collection_name, - url: Some(result.url.clone()), - host: Some(get_tab_host(&result.url)), - captured_at: Some(result.captured_at), - score: Some(round_score(semantic_score_from_distance(result.score))), - } - }) - .collect() -} - -async fn air_synthesize_sections( - state: &State<'_, Backend>, - lens: &str, - lens_kind: AirLensKind, - sources: &[AirDossierSource], - seed_answer: Option<&str>, - ice_map: Option<&str>, -) -> Cmd<(String, String)> { - let settings = load_settings(&state.paths.settings_path).await?; - let citations = sources - .iter() - .enumerate() - .map(|(index, source)| SearchResult { - id: source.id.clone(), - collection_id: source.collection_name.clone().unwrap_or_default(), - capture_id: source.id.clone(), - app_id: "air".to_string(), - title: source.title.clone(), - url: source.url.clone().unwrap_or_else(|| format!("aether://air/source/{}", index + 1)), - captured_at: source.captured_at.clone().unwrap_or_else(now), - chunk_index: index, - text: source.excerpt.clone(), - score: source.score.unwrap_or(0.0), - }) - .collect::>(); - let source_rule = if citations.is_empty() { - "No local source excerpts matched. Say that coverage is currently sparse." - } else { - "Use only the numbered local source excerpts. Cite every concrete claim with bracket citations like [1] or [2]." - }; - let prompt = format!( - "Render a concise Markdown dossier for Æther AiR.\nLens kind: {}\nLens: {lens}\n{source_rule}\n\nInclude exactly these sections:\n## Summary\n## Key Findings\n## Source-Backed Notes\n## Unresolved Questions\n\nDo not include YAML frontmatter, a title, or a source index. Keep prose dense and professional.\n\nSeed AiON answer:\n{}\n\nSaved iCE map:\n{}", - air_lens_label(lens_kind), - seed_answer.unwrap_or("None"), - ice_map.unwrap_or("None") - ); - let result = local_chat(state, &settings, &prompt, citations, &[], None).await?; - Ok(( - result.model, - normalize_model_markdown_citations(&result.answer, sources.len()), - )) -} - -struct AirMarkdownInput<'a> { - title: &'a str, - lens: &'a str, - lens_kind: AirLensKind, - generated_at: &'a str, - model: &'a str, - sources: &'a [AirDossierSource], - synthesized_sections: Option<&'a str>, - seed_answer: Option<&'a str>, - ice_map: Option<&'a str>, -} - -fn build_air_markdown(input: AirMarkdownInput<'_>) -> String { - let tags = air_tags(input.lens) - .into_iter() - .map(|tag| yaml_string(&tag)) - .collect::>() - .join(", "); - let mut markdown = format!( - "---\ntitle: {}\ncreated: {}\naether_lens: {}\nsource_count: {}\ntags: [{}]\nmodel: {}\ntype: aether-dossier\n---\n\n# {}\n\n_Rendered by AiR from the {} lens on {}._\n\n", - yaml_string(input.title), - yaml_string(input.generated_at), - yaml_string(input.lens), - input.sources.len(), - tags, - yaml_string(input.model), - markdown_escape(input.title), - air_lens_label(input.lens_kind), - markdown_escape(input.generated_at) - ); - - if let Some(sections) = input - .synthesized_sections - .map(str::trim) - .filter(|sections| !sections.is_empty()) - { - markdown.push_str(sections); - markdown.push_str("\n\n"); - } else { - markdown.push_str("## Summary\n\n"); - if input.sources.is_empty() { - markdown.push_str(&format!( - "No local sources matched [[{}]] yet. This dossier preserves the requested lens and can be regenerated after more captures are available.\n\n", - markdown_escape(input.lens) - )); - } else { - markdown.push_str(&format!( - "This dossier gathers {} local source{} around [[{}]]. It is generated as a deterministic citation scaffold from Æther's captured knowledge.\n\n", - input.sources.len(), - if input.sources.len() == 1 { "" } else { "s" }, - markdown_escape(input.lens) - )); - } - - markdown.push_str("## Key Findings\n\n"); - if input.sources.is_empty() { - markdown.push_str("- Coverage is sparse; capture or connect more relevant material before treating this lens as complete.\n\n"); - } else { - for (index, source) in input.sources.iter().take(6).enumerate() { - markdown.push_str(&format!( - "- **{}** anchors this lens with: {} [^{}]\n", - markdown_escape(&source.title), - markdown_escape(&semantic_trail_excerpt(&source.excerpt, 180)), - index + 1 - )); - } - markdown.push('\n'); - } - - markdown.push_str("## Source-Backed Notes\n\n"); - for (index, source) in input.sources.iter().enumerate() { - markdown.push_str(&format!( - "### {}. {}\n\n{}\n\n", - index + 1, - markdown_escape(&source.title), - markdown_escape(&source.excerpt) - )); - let mut meta = Vec::new(); - if let Some(collection) = &source.collection_name { - meta.push(format!("Hub: {}", markdown_escape(collection))); - } - if let Some(host) = &source.host { - meta.push(format!("Host: {}", markdown_escape(host))); - } - if let Some(captured_at) = &source.captured_at { - meta.push(format!("Captured: {}", markdown_escape(captured_at))); - } - if let Some(score) = source.score { - meta.push(format!("Match: {score:.1}")); - } - if !meta.is_empty() { - markdown.push_str(&format!("{}\n\n", meta.join(" · "))); - } - } - - if let Some(answer) = input.seed_answer.map(str::trim).filter(|answer| !answer.is_empty()) { - markdown.push_str("## AiON Seed\n\n"); - markdown.push_str(&markdown_escape(answer)); - markdown.push_str("\n\n"); - } - - if let Some(map) = input.ice_map.map(str::trim).filter(|map| !map.is_empty()) { - markdown.push_str("## iCE Map\n\n"); - markdown.push_str(&markdown_escape(map)); - markdown.push_str("\n\n"); - } - - markdown.push_str("## Unresolved Questions\n\n"); - markdown.push_str("- Which claims need fresher or primary-source confirmation?\n"); - markdown.push_str("- Which adjacent hubs should be captured next to improve coverage?\n\n"); - } - - markdown.push_str("## Source Index\n\n"); - if input.sources.is_empty() { - markdown.push_str("No source index entries were available for this render.\n"); - } else { - for (index, source) in input.sources.iter().enumerate() { - let title = markdown_escape(&source.title); - let collection = source - .collection_name - .as_deref() - .map(markdown_escape) - .unwrap_or_else(|| "Knowledge Hub".to_string()); - let captured_at = source - .captured_at - .as_deref() - .map(markdown_escape) - .unwrap_or_else(|| "unknown capture date".to_string()); - if let Some(url) = &source.url { - markdown.push_str(&format!( - "[^{}]: [{}]({}) — {} · {}\n", - index + 1, - title, - url.replace(')', "%29"), - collection, - captured_at - )); - } else { - markdown.push_str(&format!( - "[^{}]: {} — {} · {}\n", - index + 1, - title, - collection, - captured_at - )); - } - } - } - markdown -} - -async fn resolve_air_export_dir(paths: &DataPaths, create: bool) -> Cmd { - let preferred = documents_air_dir(); - let candidates = preferred - .into_iter() - .chain(std::iter::once(paths.air_exports_path.clone())) - .collect::>(); - for candidate in candidates { - if !create { - if candidate.exists() - || candidate - .parent() - .is_some_and(|parent| parent.exists() && parent.is_dir()) - { - return Ok(candidate); - } - continue; - } - if tokio::fs::create_dir_all(&candidate).await.is_ok() { - return Ok(candidate); - } - } - if create { - Err("Could not create an AiR export folder.".to_string()) - } else { - Ok(paths.air_exports_path.clone()) - } -} - -fn documents_air_dir() -> Option { - env::var_os("HOME") - .or_else(|| env::var_os("USERPROFILE")) - .map(PathBuf::from) - .map(|home| home.join("Documents").join("Æther").join("AiR")) -} - -async fn air_list_recent(paths: &DataPaths) -> Cmd> { - let mut files = Vec::::new(); - let mut directories = Vec::new(); - if let Some(documents) = documents_air_dir() { - directories.push(documents); - } - directories.push(paths.air_exports_path.clone()); - - for directory in directories { - let Ok(mut entries) = tokio::fs::read_dir(&directory).await else { - continue; - }; - while let Some(entry) = entries.next_entry().await.map_err(|error| error.to_string())? { - let path = entry.path(); - if !path - .extension() - .and_then(|extension| extension.to_str()) - .is_some_and(|extension| extension.eq_ignore_ascii_case("md")) - { - continue; - } - let filename = path - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("aether-dossier.md") - .to_string(); - let metadata = entry.metadata().await.ok(); - let rendered_at = metadata - .and_then(|metadata| metadata.modified().ok()) - .map(|modified| DateTime::::from(modified).to_rfc3339_opts(chrono::SecondsFormat::Millis, true)) - .unwrap_or_else(now); - let content = tokio::fs::read_to_string(&path).await.unwrap_or_default(); - files.push(AirRecentFile { - title: air_frontmatter_value(&content, "title") - .unwrap_or_else(|| air_title_from_filename(&filename)), - lens: air_frontmatter_value(&content, "aether_lens").unwrap_or_default(), - source_count: air_frontmatter_value(&content, "source_count") - .and_then(|value| value.parse::().ok()) - .unwrap_or(0), - path: path.display().to_string(), - filename, - rendered_at, - }); - } - } - files.sort_by(|left, right| right.rendered_at.cmp(&left.rendered_at)); - files.truncate(12); - Ok(files) -} - -fn air_frontmatter_value(markdown: &str, key: &str) -> Option { - let mut lines = markdown.lines(); - if lines.next()? != "---" { - return None; - } - let prefix = format!("{key}:"); - for line in lines { - if line == "---" { - break; - } - if let Some(value) = line.strip_prefix(&prefix) { - return Some(value.trim().trim_matches('"').replace("\\\"", "\"")); - } - } - None -} - -fn air_dossier_filename(title: &str, generated_at: &str) -> String { - let _ = generated_at; - let filename = title - .trim() - .chars() - .map(|char| match char { - '/' | '\\' | '\0' => '-', - char if char.is_control() => ' ', - char => char, - }) - .collect::() - .split_whitespace() - .collect::>() - .join(" ") - .trim_matches(['.', ' ']) - .to_string(); - if filename.is_empty() { - "AiR Dossier.md".to_string() - } else { - format!("{filename}.md") - } -} - -fn air_title_from_filename(filename: &str) -> String { - filename - .trim_end_matches(".md") - .split('-') - .filter(|part| !part.chars().all(|char| char.is_ascii_digit())) - .take(8) - .map(|part| { - let mut chars = part.chars(); - match chars.next() { - Some(first) => format!("{}{}", first.to_uppercase(), chars.as_str()), - None => String::new(), - } - }) - .collect::>() - .join(" ") -} - -fn yaml_string(value: &str) -> String { - format!( - "\"{}\"", - value - .replace('\\', "\\\\") - .replace('"', "\\\"") - .replace('\n', " ") - .replace('\r', " ") - ) -} - -fn markdown_escape(value: &str) -> String { - strip_numeric_bracket_markers(value) - .replace('\r', "") - .trim() - .to_string() -} - -fn air_tags(lens: &str) -> Vec { - let mut tags = vec!["aether".to_string(), "air".to_string(), "dossier".to_string()]; - for word in lens - .split(|char: char| !char.is_alphanumeric()) - .filter(|word| word.chars().count() >= 3) - .take(4) - { - tags.push(word.to_lowercase()); - } - tags.sort(); - tags.dedup(); - tags -} - -fn air_lens_label(kind: AirLensKind) -> &'static str { - match kind { - AirLensKind::Topic => "topic", - AirLensKind::Flow => "Flow", - AirLensKind::Hub => "hub", - AirLensKind::Answer => "AiON answer", - AirLensKind::Iceberg => "iCE", - } -} - -fn normalize_model_markdown_citations(markdown: &str, source_count: usize) -> String { - normalize_answer_citations(&clean_model_output(markdown), source_count) -} - -fn semantic_trail_default_query(captured: &CapturedPage) -> String { - normalize_captured_text(&format!( - "{}\n\n{}", - captured.title, - semantic_trail_excerpt(&captured.text, 1600) - )) -} - -fn semantic_trail_items( - candidates: Vec, - limit: usize, -) -> Vec { - let mut items = Vec::::new(); - let mut indexes = HashMap::::new(); - - for candidate in candidates { - let key = normalize_capture_url_key(&candidate.chunk.url); - if let Some(index) = indexes.get(&key).copied() { - let item = &mut items[index]; - item.excerpt = merge_semantic_trail_excerpts(&item.excerpt, &candidate.chunk.text); - for reason in candidate.reasons { - add_semantic_trail_reason(&mut item.reasons, reason); - } - continue; - } - - if items.len() >= limit { - continue; - } - - indexes.insert(key, items.len()); - items.push(SemanticTrailItem { - id: candidate.chunk.capture_id.clone(), - collection_id: candidate.chunk.collection_id.clone(), - collection_name: candidate.collection_name, - capture_id: candidate.chunk.capture_id, - app_id: candidate.chunk.app_id, - title: candidate.chunk.title, - url: candidate.chunk.url.clone(), - host: get_tab_host(&candidate.chunk.url), - captured_at: candidate.chunk.captured_at, - chunk_index: candidate.chunk.chunk_index, - excerpt: semantic_trail_excerpt(&candidate.chunk.text, 520), - score: candidate.score, - reasons: candidate.reasons, - }); - } - - items -} - -fn semantic_trail_score_breakdown( - distance: f64, - captured_at: &str, -) -> SemanticTrailScoreBreakdown { - // Relatedness is about meaning across the whole library, regardless of where a source - // came from. Semantic similarity dominates; recency is only a gentle tiebreaker. - let semantic = semantic_score_from_distance(distance); - let recency = semantic_trail_recency_score(captured_at); - let total = round_score((semantic * 0.92) + (recency * 0.08)); - - SemanticTrailScoreBreakdown { - total, - semantic, - recency, - } -} - -fn semantic_score_from_distance(distance: f64) -> f64 { - if !distance.is_finite() { - return 0.0; - } - round_score((1.0 - (distance / 1.2)).clamp(0.0, 1.0) * 100.0) -} - -fn semantic_trail_recency_score(captured_at: &str) -> f64 { - let Ok(parsed) = DateTime::parse_from_rfc3339(captured_at) else { - return 0.0; - }; - let days = Utc::now() - .signed_duration_since(parsed.with_timezone(&Utc)) - .num_days() - .max(0); - match days { - 0..=7 => 100.0, - 8..=30 => 80.0, - 31..=90 => 60.0, - 91..=180 => 40.0, - 181..=365 => 25.0, - _ => 10.0, - } -} - -fn semantic_trail_reasons( - score: &SemanticTrailScoreBreakdown, - same_collection: bool, -) -> Vec { - let mut reasons = Vec::new(); - if score.semantic >= 55.0 { - reasons.push(SemanticTrailReason::SemanticMatch); - } - if score.recency >= 80.0 { - reasons.push(SemanticTrailReason::RecentCapture); - } - if same_collection { - reasons.push(SemanticTrailReason::SameCollection); - } - if reasons.is_empty() { - reasons.push(SemanticTrailReason::SemanticMatch); - } - reasons -} - -fn semantic_trail_edges( - root: &SemanticTrailRoot, - items: &[SemanticTrailItem], -) -> Vec { - let mut edges = Vec::new(); - for item in items.iter().take(12) { - push_semantic_trail_edge( - &mut edges, - "root", - &item.id, - SemanticTrailEdgeKind::SemanticMatch, - item.score.total, - ); - if !root.host.is_empty() && root.host == item.host { - push_semantic_trail_edge( - &mut edges, - "root", - &item.id, - SemanticTrailEdgeKind::SameHost, - item.score.total, - ); - } - } - - for left_index in 0..items.len().min(8) { - for right_index in (left_index + 1)..items.len().min(8) { - let left = &items[left_index]; - let right = &items[right_index]; - let weight = left.score.total.min(right.score.total); - if left.collection_id == right.collection_id { - push_semantic_trail_edge( - &mut edges, - &left.id, - &right.id, - SemanticTrailEdgeKind::SameCollection, - weight, - ); - } else if !left.host.is_empty() && left.host == right.host { - push_semantic_trail_edge( - &mut edges, - &left.id, - &right.id, - SemanticTrailEdgeKind::SameHost, - weight, - ); - } - } - } - - edges -} - -fn push_semantic_trail_edge( - edges: &mut Vec, - from: &str, - to: &str, - kind: SemanticTrailEdgeKind, - weight: f64, -) { - if edges.len() >= 36 || from == to { - return; - } - edges.push(SemanticTrailEdge { - from: from.to_string(), - to: to.to_string(), - kind, - weight: round_score(weight), - }); -} - -fn add_semantic_trail_reason( - reasons: &mut Vec, - reason: SemanticTrailReason, -) { - if !reasons.contains(&reason) { - reasons.push(reason); - } -} - -fn merge_semantic_trail_excerpts(existing: &str, next: &str) -> String { - let next = semantic_trail_excerpt(next, 360); - if next.is_empty() || existing.contains(&next) { - return existing.to_string(); - } - semantic_trail_excerpt(&format!("{existing}\n\n[…]\n\n{next}"), 920) -} - -fn semantic_trail_excerpt(text: &str, limit: usize) -> String { - let normalized = normalize_captured_text(text); - if normalized.chars().count() <= limit { - return normalized; - } - let mut excerpt = normalized.chars().take(limit).collect::(); - excerpt.push('…'); - excerpt -} - -fn round_score(value: f64) -> f64 { - (value * 10.0).round() / 10.0 -} - -fn capture_chunk_settings(_paths: &DataPaths, _settings: &UserSettings) -> (usize, usize) { - (DEFAULT_CAPTURE_CHUNK_SIZE, DEFAULT_CAPTURE_CHUNK_OVERLAP) -} - -fn current_page_search_result( - captured: &CapturedPage, - collection_id: Option<&str>, - chunk_index: usize, - score: f64, - text: String, -) -> SearchResult { - SearchResult { - id: format!("current-{}", uuid()), - collection_id: collection_id - .map(ToString::to_string) - .unwrap_or_else(|| "current-page".to_string()), - capture_id: "current-page".to_string(), - app_id: "browser".to_string(), - title: captured.title.clone(), - url: captured.url.clone(), - captured_at: now(), - chunk_index, - text, - score, - } -} - -// The current page isn't pre-indexed like a knowledge hub, so rank its chunks at -// ask-time. We mirror the hub's semantic retrieval (embed the query + chunks, rank -// by cosine distance, keep the best matches) and fall back to lexical scoring only -// when no embedding model is available. Returning several chunks instead of the -// single best is what lets the model actually answer: dedupe_citations later merges -// these same-URL chunks into one context-dense citation, just like the hub. -async fn current_page_citations( - state: &State<'_, Backend>, - settings: &UserSettings, - captured: CapturedPage, - prompt: &str, - collection_id: Option<&str>, - limit: usize, -) -> Vec { - if limit == 0 { - return Vec::new(); - } - let (chunk_size, chunk_overlap) = capture_chunk_settings(&state.paths, settings); - let chunks = split_text(&captured.text, chunk_size, chunk_overlap); - if chunks.is_empty() { - return Vec::new(); - } - - if let Some(ranked) = semantic_rank_chunks(state, settings, &chunks, prompt, limit).await { - return ranked - .into_iter() - .map(|(index, distance)| { - current_page_search_result( - &captured, - collection_id, - index, - distance, - chunks[index].clone(), - ) - }) - .collect(); - } - - // Lexical fallback (no embedding model): keep the highest-scoring chunks. - let mut scored = chunks - .iter() - .enumerate() - .map(|(index, text)| (lexical_relevance_score(text, prompt), index)) - .filter(|(score, _)| *score > 0.0) - .collect::>(); - scored.sort_by(|left, right| right.0.partial_cmp(&left.0).unwrap_or(Ordering::Equal)); - - if scored.is_empty() { - // Nothing matched lexically — anchor on the start of the page rather than - // returning nothing. - let first = chunks.into_iter().next().unwrap_or_default(); - return vec![current_page_search_result( - &captured, - collection_id, - 0, - 0.0, - first, - )]; - } - - scored - .into_iter() - .take(limit) - .map(|(score, index)| { - current_page_search_result(&captured, collection_id, index, score, chunks[index].clone()) - }) - .collect() -} - -// Embed the query alongside the page chunks in one batch, then order chunk indices -// by ascending cosine distance. Returns None when embedding is unavailable so the -// caller can fall back to lexical scoring. -async fn semantic_rank_chunks( - state: &State<'_, Backend>, - settings: &UserSettings, - chunks: &[String], - prompt: &str, - limit: usize, -) -> Option> { - let mut inputs = Vec::with_capacity(chunks.len() + 1); - inputs.push(embedding_query_input(&state.paths, settings, prompt)); - inputs.extend(chunks.iter().cloned()); - - let embeddings = local_embed(state, settings, inputs).await.ok()?; - if embeddings.len() != chunks.len() + 1 { - return None; - } - - let query_vector = &embeddings[0]; - let mut scored = embeddings[1..] - .iter() - .enumerate() - .map(|(index, vector)| (cosine_distance(query_vector, vector), index)) - .collect::>(); - scored.sort_by(|left, right| left.0.partial_cmp(&right.0).unwrap_or(Ordering::Equal)); - Some( - scored - .into_iter() - .take(limit) - .map(|(distance, index)| (index, distance)) - .collect(), - ) -} - -fn lexical_relevance_score(text: &str, query: &str) -> f64 { - let terms = query_terms(query); - if terms.is_empty() { - return 0.0; - } - let haystack = text.to_lowercase(); - terms - .iter() - .map(|term| lexical_term_score(&haystack, term)) - .sum() -} - -fn lexical_term_score(haystack: &str, term: &str) -> f64 { - let occurrences = haystack.matches(term).count(); - if occurrences > 0 { - // Reward repeated mentions so a chunk that actually discusses the term beats - // one that merely name-drops it once. Without this, every matching chunk ties - // and the tie-break is arbitrary. - return 2.0 + (term.len() as f64 / 10.0) + (occurrences.saturating_sub(1) as f64) * 0.5; - } - let stem_len = term.len().min(6); - if stem_len >= 5 && haystack.contains(&term[..stem_len]) { - return 1.25 + (stem_len as f64 / 12.0); - } - if let Some(singular) = term.strip_suffix('s') { - if singular.len() >= 5 && haystack.contains(singular) { - return 1.5 + (singular.len() as f64 / 12.0); - } - } - 0.0 -} - -fn query_terms(query: &str) -> Vec { - let stopwords = [ - "a", "an", "and", "are", "as", "at", "be", "by", "can", "do", "does", "for", "from", "how", - "i", "in", "is", "it", "me", "most", "of", "on", "or", "should", "the", "this", "to", - "was", "were", "what", "when", "where", "which", "who", "why", "with", - ]; - let stopwords = stopwords.into_iter().collect::>(); - let mut seen = HashSet::new(); - query - .split(|character: char| !character.is_alphanumeric()) - .map(str::trim) - .map(str::to_lowercase) - .filter(|term| term.len() > 2 && !stopwords.contains(term.as_str())) - .filter(|term| seen.insert(term.clone())) - .collect() -} - -async fn system_status(state: &State<'_, Backend>) -> Cmd { - let settings = load_settings(&state.paths.settings_path).await?; - let library = load_library(&state.paths.library_path).await?; - let catalog = model_catalog(&state.paths, &settings.local_model); - Ok(SystemStatus { - runtime_ready: catalog.chat_model.is_some() || catalog.embedding_model.is_some(), - runtime_name: LOCAL_RUNTIME_NAME.to_string(), - embedding_model: catalog.embedding_model.as_ref().map(path_to_model_value), - chat_model: catalog.chat_model.as_ref().map(path_to_model_value), - available_models: catalog.models.iter().map(path_to_model_value).collect(), - chat_models: catalog - .models - .iter() - .filter(|path| is_chat_model(path)) - .map(path_to_model_value) - .collect(), - embedding_models: catalog - .models - .iter() - .filter(|path| is_embedding_model(path)) - .map(path_to_model_value) - .collect(), - model_dir: state.paths.models_path.display().to_string(), - db_path: state.paths.db_path.display().to_string(), - library_path: state.paths.library_path.display().to_string(), - collections: library.collections, - error: catalog.error, - }) -} - -async fn load_library(path: &Path) -> Cmd { - read_json_or_default(path).await -} - -async fn load_settings(path: &Path) -> Cmd { - read_json_or_default(path).await -} - -async fn persist_update_last_checked_at(paths: &DataPaths, checked_at: &str) -> Cmd<()> { - let mut settings = load_settings(&paths.settings_path).await?; - settings.updates.last_checked_at = Some(checked_at.to_string()); - save_json(&paths.settings_path, &settings).await -} - -fn release_version_from_tag(tag: &str) -> String { - tag.trim() - .trim_start_matches('v') - .trim_start_matches('V') - .to_string() -} - -fn version_is_newer(candidate: &str, current: &str) -> bool { - let candidate_parts = version_parts(candidate); - let current_parts = version_parts(current); - let max_len = candidate_parts.len().max(current_parts.len()).max(3); - for index in 0..max_len { - let candidate_value = candidate_parts.get(index).copied().unwrap_or_default(); - let current_value = current_parts.get(index).copied().unwrap_or_default(); - if candidate_value > current_value { - return true; - } - if candidate_value < current_value { - return false; - } - } - false -} - -fn version_parts(version: &str) -> Vec { - version - .trim() - .trim_start_matches('v') - .trim_start_matches('V') - .split(|character: char| !character.is_ascii_digit()) - .filter(|part| !part.is_empty()) - .filter_map(|part| part.parse::().ok()) - .collect() -} - -async fn load_icebergs(path: &Path) -> Cmd { - read_json_or_default(path).await -} - -async fn load_conversations(path: &Path) -> Cmd { - read_json_or_default(path).await -} - -async fn load_session(path: &Path) -> Cmd { - read_json_or_default(path).await -} - -// Snapshots the open tabs. Called after any tab mutation rather than at exit, because -// force_exit() hard-kills the process on quit and never gives a shutdown hook a turn. -async fn persist_session_tabs(state: &State<'_, Backend>) -> Cmd<()> { - let (tabs, active_tab_id) = { - let guard = lock_tabs(state)?; - let tabs = guard - .tabs - .iter() - // A tab parked on the internal start page has nothing to reopen. - .filter(|tab| tab.url != START_PAGE_URL && !tab.url.starts_with("aether://")) - .map(|tab| SessionTab { - id: tab.id.clone(), - url: tab.url.clone(), - title: tab.title.clone(), - }) - .collect::>(); - (tabs, guard.active_tab_id.clone()) - }; - - let mut session = load_session(&state.paths.session_path).await?; - session.tabs = tabs; - session.active_tab_id = active_tab_id; - save_json(&state.paths.session_path, &session).await -} - -// Fire-and-forget wrapper for the sync command paths. A failed session write must -// never make a tab action fail. -fn schedule_session_save(app: &AppHandle) { - let app = app.clone(); - tauri::async_runtime::spawn(async move { - let state = app.state::(); - if let Err(error) = persist_session_tabs(&state).await { - eprintln!("aether: could not save session: {error}"); - } - }); -} - -async fn persist_session_window(paths: &DataPaths, window: SessionWindow) -> Cmd<()> { - let mut session = load_session(&paths.session_path).await?; - session.window = Some(window); - save_json(&paths.session_path, &session).await -} - -#[cfg(desktop)] -fn file_name_of(path: &Path) -> String { - path.file_name() - .map(|name| name.to_string_lossy().to_string()) - .unwrap_or_else(|| "download".to_string()) -} - -// Derives a safe filename from a URL. Path separators and control characters are -// stripped so a crafted URL cannot write outside the downloads directory. -#[cfg(desktop)] -fn file_name_from_url(url: &Url) -> String { - let raw = url - .path_segments() - .and_then(|segments| segments.filter(|segment| !segment.is_empty()).next_back()) - .unwrap_or("download"); - let decoded = percent_decode_download_name(raw); - let cleaned = decoded - .chars() - .filter(|character| { - !character.is_control() && !matches!(character, '/' | '\\' | ':' | '\0') - }) - .collect::(); - let trimmed = cleaned.trim().trim_start_matches('.').to_string(); - if trimmed.is_empty() { - "download".to_string() - } else { - trimmed.chars().take(180).collect() - } -} - -#[cfg(desktop)] -fn percent_decode_download_name(raw: &str) -> String { - let bytes = raw.as_bytes(); - let mut out = Vec::with_capacity(bytes.len()); - let mut index = 0; - while index < bytes.len() { - if bytes[index] == b'%' && index + 2 < bytes.len() { - if let Ok(byte) = u8::from_str_radix(&raw[index + 1..index + 3], 16) { - out.push(byte); - index += 3; - continue; - } - } - out.push(bytes[index]); - index += 1; - } - String::from_utf8_lossy(&out).to_string() -} - -// Never overwrite an existing file: a second download of the same name becomes -// "name (2).ext" the way a browser does. -#[cfg(desktop)] -fn resolve_download_destination(app: &AppHandle, url: &Url) -> Option { - let dir = app - .path() - .download_dir() - .ok() - .or_else(|| app.path().home_dir().ok().map(|home| home.join("Downloads")))?; - if fs::create_dir_all(&dir).is_err() { - return None; - } - - let name = file_name_from_url(url); - let candidate = dir.join(&name); - if !candidate.exists() { - return Some(candidate); - } - - let path = Path::new(&name); - let stem = path - .file_stem() - .map(|stem| stem.to_string_lossy().to_string()) - .unwrap_or_else(|| "download".to_string()); - let extension = path - .extension() - .map(|extension| format!(".{}", extension.to_string_lossy())) - .unwrap_or_default(); - for index in 2..1000 { - let candidate = dir.join(format!("{stem} ({index}){extension}")); - if !candidate.exists() { - return Some(candidate); - } - } - None -} - -#[cfg(desktop)] -fn emit_download_event( - app: &AppHandle, - status: &str, - filename: &str, - path: Option<&Path>, - url: &str, -) { - let _ = app.emit( - AETHER_DOWNLOAD_EVENT, - DownloadProgress { - status: status.to_string(), - filename: filename.to_string(), - path: path.map(|path| path.display().to_string()), - url: url.to_string(), - }, - ); -} - -#[cfg(desktop)] -fn restore_session_tabs(state: &State, session: &SessionData) { - if session.tabs.is_empty() { - return; - } - let Ok(mut tabs) = state.tabs.lock() else { - return; - }; - let restored = session - .tabs - .iter() - .map(|tab| { - let mut managed = ManagedTab::new("browser", &tab.url); - // Keep the stored id so the saved active-tab id still resolves. - managed.id = tab.id.clone(); - if !tab.title.is_empty() { - managed.title = tab.title.clone(); - } - managed - }) - .collect::>(); - - let active = restored - .iter() - .any(|tab| tab.id == session.active_tab_id) - .then(|| session.active_tab_id.clone()) - .unwrap_or_else(|| restored[0].id.clone()); - - tabs.active_tab_id = active; - tabs.tabs = restored; - // The dashboard is still the landing surface; restored tabs wait behind it. - tabs.dashboard_open = true; -} - -#[cfg(desktop)] -fn current_window_geometry(window: &Window) -> Option { - let scale = window.scale_factor().ok()?; - let size = window.inner_size().ok()?.to_logical::(scale); - let position = window.outer_position().ok()?.to_logical::(scale); - Some(SessionWindow { - width: size.width, - height: size.height, - x: position.x, - y: position.y, - }) -} - -#[cfg(desktop)] -fn apply_session_window(window: &Window, geometry: SessionWindow) { - // Guard against a stored geometry that would open the window off-screen or too - // small to use (a display was unplugged, or the config was hand-edited). - if geometry.width < 480.0 || geometry.height < 360.0 { - return; - } - let _ = window.set_size(Size::Logical(LogicalSize::new( - geometry.width, - geometry.height, - ))); - if geometry.x > -20_000.0 && geometry.y > -20_000.0 { - let _ = window.set_position(Position::Logical(LogicalPosition::new( - geometry.x, geometry.y, - ))); - } -} - -#[cfg(desktop)] -fn save_window_geometry_now(app: &AppHandle) { - let Some(window) = app.get_window("main") else { - return; - }; - let Some(geometry) = current_window_geometry(&window) else { - return; - }; - let app = app.clone(); - tauri::async_runtime::spawn(async move { - let state = app.state::(); - if let Err(error) = persist_session_window(&state.paths, geometry).await { - eprintln!("aether: could not save window geometry: {error}"); - } - }); -} - -// Resize and move fire continuously while dragging, so throttle the writes. The -// CloseRequested handler catches whatever the throttle skipped. -#[cfg(desktop)] -fn schedule_window_geometry_save(app: &AppHandle) { - let state = app.state::(); - { - let Ok(mut last) = state.window_geometry_saved_at.lock() else { - return; - }; - if let Some(previous) = *last { - if previous.elapsed() < Duration::from_millis(500) { - return; - } - } - *last = Some(Instant::now()); - } - save_window_geometry_now(app); -} - -fn conversation_thread_key(collection_id: Option<&str>) -> String { - collection_id - .filter(|id| !id.is_empty()) - .unwrap_or(CURRENT_PAGE_THREAD_KEY) - .to_string() -} - -async fn conversation_thread(paths: &DataPaths, collection_id: Option<&str>) -> Vec { - let key = conversation_thread_key(collection_id); - load_conversations(&paths.conversations_path) - .await - .unwrap_or_default() - .threads - .remove(&key) - .unwrap_or_default() -} - -async fn append_conversation_turn( - paths: &DataPaths, - collection_id: Option<&str>, - turn: ConversationTurn, -) -> Cmd<()> { - let key = conversation_thread_key(collection_id); - let mut data = load_conversations(&paths.conversations_path).await?; - let thread = data.threads.entry(key).or_default(); - thread.push(turn); - if thread.len() > MAX_THREAD_TURNS { - let overflow = thread.len() - MAX_THREAD_TURNS; - thread.drain(0..overflow); - } - save_json(&paths.conversations_path, &data).await -} - -async fn load_vectors(path: &Path) -> Cmd { - // A v1 store carries its vectors inline, so it must be detected before the v2 - // deserializer silently drops them (`vector` is #[serde(skip)] there). - if let Ok(raw) = tokio::fs::read_to_string(path).await { - let declared_version = serde_json::from_str::(&raw) - .ok() - .and_then(|value| value.get("version").and_then(serde_json::Value::as_u64)); - if declared_version.unwrap_or(1) < VECTOR_STORE_VERSION as u64 { - return migrate_legacy_vectors(path, &raw).await; - } - } - - let mut data: VectorStoreData = read_json_or_default(path).await?; - hydrate_vectors(path, &mut data).await?; - Ok(data) -} - -// Reads the sidecar and attaches each chunk's vector. Chunks whose slot is missing -// from the file are dropped rather than fatal: a partial sidecar should cost the -// affected sources, not the whole library. -async fn hydrate_vectors(path: &Path, data: &mut VectorStoreData) -> Cmd<()> { - if data.chunks.is_empty() { - return Ok(()); - } - if data.dim == 0 { - eprintln!("aether: vector store has chunks but no dimension; dropping stale metadata"); - data.chunks.clear(); - return Ok(()); - } - - let vector_path = vector_data_path(path); - let bytes = match tokio::fs::read(&vector_path).await { - Ok(bytes) => bytes, - Err(error) => { - eprintln!( - "aether: vector sidecar {} unreadable ({error}); captures need re-indexing", - vector_path.display() - ); - data.chunks.clear(); - return Ok(()); - } - }; - - let stride = data.dim * 4; - let available = (bytes.len() / stride) as u64; - let before = data.chunks.len(); - - // Parked chunks hold no slot, so they survive regardless of what the sidecar has. - data.chunks - .retain(|chunk| chunk.needs_reembed || chunk.vector_slot < available); - for chunk in &mut data.chunks { - if chunk.needs_reembed { - continue; - } - let start = chunk.vector_slot as usize * stride; - chunk.vector = bytes[start..start + stride] - .chunks_exact(4) - .map(|word| f32::from_le_bytes([word[0], word[1], word[2], word[3]])) - .collect(); - } - - if data.chunks.len() != before { - eprintln!( - "aether: dropped {} chunk(s) missing from the vector sidecar", - before - data.chunks.len() - ); - } - Ok(()) -} - -async fn migrate_legacy_vectors(path: &Path, raw: &str) -> Cmd { - let legacy: LegacyVectorStoreData = match serde_json::from_str(raw) { - Ok(legacy) => legacy, - Err(error) => { - eprintln!("aether: could not read legacy vector store ({error}); starting fresh"); - return Ok(VectorStoreData::default()); - } - }; - - // v1 imposed no single width, so a store written across an embedding-model change - // holds a mix. v2 has one stride, so the migration must pick one width and park the - // rest. Choosing the width the most chunks use preserves the most vectors; picking - // the first chunk's width would hand the store to whichever model happened to be - // written first, which on a real install was the *older* model. - let mut data = VectorStoreData { - dim: majority_vector_dim(&legacy.chunks), - ..VectorStoreData::default() - }; - data.push_chunks(legacy.chunks.into_iter().map(|chunk| ChunkRecord { - id: chunk.id, - vector: chunk.vector, - vector_slot: 0, - needs_reembed: false, - text: chunk.text, - collection_id: chunk.collection_id, - capture_id: chunk.capture_id, - title: chunk.title, - url: chunk.url, - app_id: chunk.app_id, - captured_at: chunk.captured_at, - chunk_index: chunk.chunk_index, - })); - - let parked = data.pending_reembed_count(); - eprintln!( - "aether: migrating {} chunk(s) to the binary vector store at {} dims{}", - data.chunks.len(), - data.dim, - if parked > 0 { - format!(", parking {parked} from another embedding model for re-indexing") - } else { - String::new() - } - ); - // Keep the pre-migration file under a name no ordinary save touches. The `.bak` - // rotation is one generation deep, so the next capture would overwrite a v1 backup - // with a v2 copy and leave no way back to the original vectors. - archive_pre_migration_store(path, raw).await; - write_vector_sidecar(path, &data, 0).await?; - save_vector_metadata(path, &data).await?; - Ok(data) -} - -// The width the most chunks share, so the migration keeps the largest set of usable -// vectors. Ties break toward the wider vector, which is the newer model in practice. -fn majority_vector_dim(chunks: &[LegacyChunkRecord]) -> usize { - let mut counts: HashMap = HashMap::new(); - for chunk in chunks { - if !chunk.vector.is_empty() { - *counts.entry(chunk.vector.len()).or_insert(0) += 1; - } - } - counts - .into_iter() - .max_by_key(|(dim, count)| (*count, *dim)) - .map(|(dim, _)| dim) - .unwrap_or(0) -} - -// Writes the untouched v1 bytes alongside the store, once. A second migration must not -// clobber the first archive: that copy is the only remaining source for any vector the -// migration parked. -async fn archive_pre_migration_store(path: &Path, raw: &str) { - let mut name = path.file_stem().unwrap_or_default().to_os_string(); - name.push(PRE_MIGRATION_SUFFIX); - let target = path.with_file_name(name); - if tokio::fs::try_exists(&target).await.unwrap_or(false) { - return; - } - match write_bytes_atomically(&target, raw.as_bytes(), false).await { - Ok(()) => eprintln!( - "aether: archived the pre-migration vector store at {}", - target.display() - ), - Err(error) => eprintln!("aether: could not archive the pre-migration store: {error}"), - } -} - -async fn with_vectors_read( - state: &State<'_, Backend>, - read: impl FnOnce(&VectorStoreData) -> T, -) -> Cmd { - { - let guard = state.vectors.read().await; - if let Some(vectors) = guard.as_ref() { - return Ok(read(vectors)); - } - } - let mut guard = state.vectors.write().await; - if guard.is_none() { - *guard = Some(load_vectors(&state.paths.chunks_path).await?); - } - Ok(read(guard.as_ref().expect("vector store cache"))) -} - -async fn with_vectors_mut( - state: &State<'_, Backend>, - mutate: impl FnOnce(&mut VectorStoreData) -> T, -) -> Cmd { - let mut guard = state.vectors.write().await; - if guard.is_none() { - *guard = Some(load_vectors(&state.paths.chunks_path).await?); - } - let vectors = guard.as_mut().expect("vector store cache"); - let result = mutate(vectors); - save_vectors(&state.paths.chunks_path, vectors).await?; - Ok(result) -} - -// Vector rows are large and machine-managed, so the metadata is persisted as compact -// JSON instead of the pretty format used for small user-editable stores. -async fn save_vector_metadata(path: &Path, data: &VectorStoreData) -> Cmd<()> { - let raw = serde_json::to_string(data).map_err(|error| error.to_string())?; - write_store_durably(path, raw.as_bytes()).await -} - -// Writes vectors for every chunk whose slot is at or beyond `slots_present`. -// `slots_present == 0` rewrites the sidecar from scratch. -async fn write_vector_sidecar( - path: &Path, - data: &VectorStoreData, - slots_present: u64, -) -> Cmd<()> { - let vector_path = vector_data_path(path); - ensure_parent_dir(&vector_path).await?; - - let mut pending = data - .chunks - .iter() - .filter(|chunk| !chunk.needs_reembed && chunk.vector_slot >= slots_present) - .collect::>(); - if pending.is_empty() && slots_present > 0 { - return Ok(()); - } - // Slot order is file order; appending out of order would corrupt the stride index. - pending.sort_by_key(|chunk| chunk.vector_slot); - - let mut buffer = Vec::with_capacity(pending.len() * data.dim.max(1) * 4); - for chunk in pending { - for value in &chunk.vector { - buffer.extend_from_slice(&value.to_le_bytes()); - } - } - - let mut file = tokio::fs::OpenOptions::new() - .create(true) - .write(true) - .append(slots_present > 0) - .truncate(slots_present == 0) - .open(&vector_path) - .await - .map_err(|error| error.to_string())?; - file.write_all(&buffer) - .await - .map_err(|error| error.to_string())?; - // The metadata written next will reference these slots, so they must be on disk - // before it lands. Orphaned vector bytes are harmless; dangling slots are not. - file.sync_all().await.map_err(|error| error.to_string()) -} - -async fn sidecar_slots_present(path: &Path, dim: usize) -> u64 { - if dim == 0 { - return 0; - } - match tokio::fs::metadata(vector_data_path(path)).await { - Ok(meta) => meta.len() / (dim as u64 * 4), - Err(_) => 0, - } -} - -// Deletes leave dead slots behind. Once they dominate the sidecar, renumber the live -// chunks and rewrite it so the file cannot grow without bound. -async fn compact_vectors_if_needed(path: &Path, data: &mut VectorStoreData) -> Cmd { - // Parked chunks hold no slot, so counting them as live would understate how much - // of the sidecar is dead and stop compaction from ever triggering. - let live = data.embedded_count(); - if data.next_slot < VECTOR_COMPACTION_MIN_SLOTS { - return Ok(false); - } - let dead = data.next_slot.saturating_sub(live); - if (dead as f64) < data.next_slot as f64 * VECTOR_COMPACTION_DEAD_RATIO { - return Ok(false); - } - - // Embedded chunks first in slot order, parked ones after, so renumbering walks - // exactly the records that occupy the sidecar. - data.chunks - .sort_by_key(|chunk| (chunk.needs_reembed, chunk.vector_slot)); - let mut next = 0_u64; - for chunk in data.chunks.iter_mut() { - if chunk.needs_reembed { - continue; - } - chunk.vector_slot = next; - next += 1; - } - data.next_slot = live; - eprintln!("aether: compacted vector store, reclaimed {dead} dead slot(s)"); - write_vector_sidecar(path, data, 0).await?; - Ok(true) -} - -async fn save_vectors(path: &Path, data: &mut VectorStoreData) -> Cmd<()> { - if !compact_vectors_if_needed(path, data).await? { - let slots_present = sidecar_slots_present(path, data.dim).await; - write_vector_sidecar(path, data, slots_present).await?; - } - save_vector_metadata(path, data).await -} - -fn backup_path(path: &Path) -> PathBuf { - let mut name = path.file_name().unwrap_or_default().to_os_string(); - name.push(BACKUP_SUFFIX); - path.with_file_name(name) -} - -fn temp_write_path(path: &Path) -> PathBuf { - let mut name = path.file_name().unwrap_or_default().to_os_string(); - name.push(TEMP_WRITE_SUFFIX); - path.with_file_name(name) -} - -async fn ensure_parent_dir(path: &Path) -> Cmd<()> { - if let Some(parent) = path.parent() { - tokio::fs::create_dir_all(parent) - .await - .map_err(|error| error.to_string())?; - } - Ok(()) -} - -// Writes bytes to a sibling temp file, fsyncs it, then renames it over the target. -// `rotate` additionally renames the previous good file to `.bak` first, so a -// crash can never leave the store both truncated and without a recoverable copy. -// -// Both renames are atomic within a directory, so the target is only ever the old -// complete file or the new complete file. The gap where the target is momentarily -// absent is covered by the backup, which `read_json_or_default` falls back to. -async fn write_bytes_atomically(path: &Path, bytes: &[u8], rotate: bool) -> Cmd<()> { - ensure_parent_dir(path).await?; - let temp = temp_write_path(path); - - // Scope the handle so it is flushed and closed before the rename. - { - let mut file = tokio::fs::File::create(&temp) - .await - .map_err(|error| error.to_string())?; - file.write_all(bytes) - .await - .map_err(|error| error.to_string())?; - // Without this the rename can land before the data does, which is exactly - // the truncated-store case this function exists to prevent. - file.sync_all().await.map_err(|error| error.to_string())?; - } - - if rotate && tokio::fs::try_exists(path).await.unwrap_or(false) { - let backup = backup_path(path); - if let Err(error) = tokio::fs::rename(path, &backup).await { - // A missing backup is recoverable; failing the whole save is not. - eprintln!( - "aether: could not rotate backup for {}: {error}", - path.display() - ); - } - } - - tokio::fs::rename(&temp, path) - .await - .map_err(|error| error.to_string()) -} - -async fn write_store_durably(path: &Path, bytes: &[u8]) -> Cmd<()> { - write_bytes_atomically(path, bytes, true).await -} - -// Quarantines an unparseable store instead of overwriting it. Losing a store to a -// bug is bad; silently replacing it with a default and destroying the evidence is -// worse, so the bad bytes are kept for manual recovery. -async fn quarantine_unreadable_store(path: &Path) { - if !tokio::fs::try_exists(path).await.unwrap_or(false) { - return; - } - let mut name = path.file_name().unwrap_or_default().to_os_string(); - name.push(format!(".corrupt-{}", now().replace(':', "-"))); - let target = path.with_file_name(name); - match tokio::fs::rename(path, &target).await { - Ok(()) => eprintln!( - "aether: kept unreadable store at {} for recovery", - target.display() - ), - Err(error) => eprintln!( - "aether: could not quarantine {}: {error}", - path.display() - ), - } -} - -// Load order: primary store, then `.bak`, then a fresh default. A parse failure on -// the primary is treated as corruption rather than as an error, so one bad file -// cannot make the app permanently unopenable. -async fn read_json_or_default(path: &Path) -> Cmd -where - T: DeserializeOwned + Default + Serialize, -{ - if let Ok(raw) = tokio::fs::read_to_string(path).await { - match serde_json::from_str::(&raw) { - Ok(value) => return Ok(value), - Err(error) => eprintln!( - "aether: {} is unreadable ({error}); trying backup", - path.display() - ), - } - } - - let backup = backup_path(path); - if let Ok(raw) = tokio::fs::read_to_string(&backup).await { - if let Ok(value) = serde_json::from_str::(&raw) { - eprintln!("aether: recovered {} from backup", path.display()); - quarantine_unreadable_store(path).await; - // Restore without rotating: the backup we just read is the only good - // copy left, and rotating here would overwrite it with the bad file. - write_bytes_atomically(path, raw.as_bytes(), false).await?; - return Ok(value); - } - eprintln!("aether: backup {} is also unreadable", backup.display()); - } - - quarantine_unreadable_store(path).await; - let data = T::default(); - let raw = serde_json::to_string_pretty(&data).map_err(|error| error.to_string())?; - write_bytes_atomically(path, format!("{raw}\n").as_bytes(), false).await?; - Ok(data) -} - -async fn save_json(path: &Path, data: &T) -> Cmd<()> { - let raw = serde_json::to_string_pretty(data).map_err(|error| error.to_string())?; - write_store_durably(path, format!("{raw}\n").as_bytes()).await -} - -async fn get_collection(path: &Path, collection_id: &str) -> Cmd { - load_library(path) - .await? - .collections - .into_iter() - .find(|collection| collection.id == collection_id) - .ok_or_else(|| "Collection not found.".to_string()) -} - -async fn local_embed( - state: &State<'_, Backend>, - settings: &UserSettings, - inputs: Vec, -) -> Cmd>> { - local_embed_with_progress(state, settings, inputs, None).await -} - -async fn local_embed_query( - state: &State<'_, Backend>, - settings: &UserSettings, - query: String, -) -> Cmd> { - local_embed( - state, - settings, - vec![embedding_query_input(&state.paths, settings, &query)], - ) - .await? - .into_iter() - .next() - .ok_or_else(|| "Local embedding model returned no embedding.".to_string()) -} - -fn embedding_query_input(paths: &DataPaths, settings: &UserSettings, query: &str) -> String { - let catalog = model_catalog(paths, &settings.local_model); - let Some(model_path) = catalog.embedding_model.as_deref() else { - return query.to_string(); - }; - let label = model_label(model_path).to_lowercase(); - - if label.contains("qwen3-embedding") { - return format!("Instruct: {QWEN3_EMBEDDING_RETRIEVAL_INSTRUCTION}\nQuery: {query}"); - } - - query.to_string() -} - -async fn local_embed_with_progress( - state: &State<'_, Backend>, - settings: &UserSettings, - inputs: Vec, - progress: Option, -) -> Cmd>> { - let catalog = model_catalog(&state.paths, &settings.local_model); - let model_path = catalog.embedding_model.ok_or_else(|| { - format!( - "No local embedding model found. Install AiON MiST/Qwen3 Embedding, add another embedding GGUF to {}, or set {AETHER_EMBEDDING_MODEL_ENV}.", - state.paths.models_path.display() - ) - })?; - let runtime = Arc::clone(&state.native_runtime); - task::spawn_blocking(move || { - let mut runtime = runtime - .lock() - .map_err(|_| "Local model runtime is unavailable.".to_string())?; - match progress { - Some(progress) => runtime.embed_with_progress(&model_path, inputs, Some(progress)), - None => runtime.embed(&model_path, inputs), - } - }) - .await - .map_err(|error| error.to_string())? -} - -async fn local_chat( - state: &State<'_, Backend>, - settings: &UserSettings, - prompt: &str, - citations: Vec, - // Prior turns for follow-up questions. Empty for one-shot callers such as AiR. - history: &[ConversationTurn], - stream: Option, -) -> Cmd { - let started_at = Instant::now(); - let catalog = model_catalog(&state.paths, &settings.local_model); - // With only the embedding model installed there is nothing to generate with, but - // retrieval still works. Returning the ranked passages is far more useful than an - // error, and it is what makes MiST-only a usable install rather than a dead end. - let Some(model_path) = catalog.chat_model else { - if citations.is_empty() { - return Err(format!( - "No local chat model is installed, and no captured passages matched. Capture a page or install a chat model in {}.", - state.paths.models_path.display() - )); - } - if let Some(stream) = &stream { - stream.citations(&citations); - } - return Ok(extractive_answer(citations, started_at.elapsed().as_secs_f64())); - }; - if let Some(stream) = &stream { - stream.citations(&citations); - } - let messages = build_chat_messages(prompt, &citations, history); - let runtime = Arc::clone(&state.native_runtime); - let cancel = Arc::clone(&state.generation_cancelled); - let model_label = model_label(&model_path); - let completion = task::spawn_blocking(move || { - let mut runtime = runtime - .lock() - .map_err(|_| "Local model runtime is unavailable.".to_string())?; - let token_stream = stream.clone(); - let on_token: Option> = token_stream - .map(|stream| Box::new(move |delta: &str| stream.delta(delta)) as Box); - let on_status: Option> = stream - .map(|stream| Box::new(move |status: String| stream.status(&status)) as Box); - runtime.complete_chat( - &model_path, - messages, - DEFAULT_GENERATION_TOKENS, - 0.2, - &cancel, - on_token, - on_status, - ) - }) - .await - .map_err(|error| error.to_string())??; - let elapsed_seconds = started_at.elapsed().as_secs_f64(); - let answer = normalize_answer_citations(&clean_model_output(&completion.text), citations.len()); - let tokens_per_second = if completion.generated_tokens > 0 && elapsed_seconds > 0.0 { - completion.generated_tokens as f64 / elapsed_seconds - } else { - 0.0 - }; - let chunks = citations.len(); - Ok(ChatResult { - answer, - model: model_label, - citations, - metrics: ChatMetrics { - generated_tokens: completion.generated_tokens, - tokens_per_second, - elapsed_seconds, - chunks, - }, - }) -} - -// The retrieval-only answer. Deliberately presented as quoted passages with their -// sources rather than as prose: nothing here was generated, and dressing excerpts up -// as an answer would imply reasoning that did not happen. -const EXTRACTIVE_MODEL_LABEL: &str = "AiON MiST (passages only)"; - -fn extractive_answer(citations: Vec, elapsed_seconds: f64) -> ChatResult { - let mut answer = String::from( - "No chat model is installed, so ÆTHER cannot write an answer. These are the passages from your library that best match the question:\n\n", - ); - for (index, citation) in citations.iter().enumerate() { - answer.push_str(&format!( - "**{}. {}** [{}]\n\n> {}\n\n", - index + 1, - citation.title.trim(), - index + 1, - semantic_trail_excerpt(citation.text.trim(), 480).replace('\n', " ") - )); - } - answer.push_str("_Install a chat model in Settings to get written answers grounded in these sources._"); - - let chunks = citations.len(); - ChatResult { - answer, - model: EXTRACTIVE_MODEL_LABEL.to_string(), - citations, - metrics: ChatMetrics { - // Nothing was generated, so the token metrics are honestly zero. - generated_tokens: 0, - tokens_per_second: 0.0, - elapsed_seconds, - chunks, - }, - } -} - -async fn local_generate_iceberg( - state: &State<'_, Backend>, - settings: &UserSettings, - topic: &str, -) -> Cmd { - let catalog = model_catalog(&state.paths, &settings.local_model); - let model_path = catalog.chat_model.ok_or_else(|| { - format!( - "No local generative GGUF model found. Add Gemma or another chat model to {} or set {AETHER_CHAT_MODEL_ENV}.", - state.paths.models_path.display() - ) - })?; - let messages = vec![ChatPromptMessage { - role: "user", - content: build_iceberg_prompt(topic), - }]; - let runtime = Arc::clone(&state.native_runtime); - let cancel = Arc::clone(&state.generation_cancelled); - let model_label = model_label(&model_path); - let completion = task::spawn_blocking(move || { - let mut runtime = runtime - .lock() - .map_err(|_| "Local model runtime is unavailable.".to_string())?; - runtime.complete_chat( - &model_path, - messages, - DEFAULT_ICEBERG_GENERATION_TOKENS, - 0.35, - &cancel, - None, - None, - ) - }) - .await - .map_err(|error| error.to_string())??; - let generated = completion.text; - if state.generation_cancelled.load(AtomicOrdering::Relaxed) { - return Err("Crystallization stopped.".to_string()); - } - let generated = clean_model_output(&generated); - Ok(IcebergResult { - keyword: topic.to_string(), - model: model_label, - items: normalize_iceberg_items(&generated)?, - generated_at: now(), - }) -} - -impl NativeModelRuntime { - fn ensure_backend(&mut self) -> Cmd<()> { - if self.backend.is_some() { - return Ok(()); - } - - let mut backend = LlamaBackend::init().map_err(|error| error.to_string())?; - backend.void_logs(); - self.backend = Some(backend); - Ok(()) - } - - fn ensure_model(&mut self, kind: NativeModelKind, path: &Path) -> Cmd<()> { - let path = canonical_model_path(path); - let current_path = match kind { - NativeModelKind::Chat => self.chat.as_ref().map(|loaded| loaded.path.as_path()), - NativeModelKind::Embedding => { - self.embedding.as_ref().map(|loaded| loaded.path.as_path()) - } - }; - if current_path == Some(path.as_path()) { - return Ok(()); - } - - self.ensure_backend()?; - let backend = self - .backend - .as_ref() - .ok_or_else(|| "Local model backend is not initialized.".to_string())?; - // Mobile: load weights into anonymous memory instead of mmapping the - // GGUF. Mmapped weight pages are ordinary page cache, and Android - // evicts them under the memory pressure the native tab WebViews - // create — after which every generated token faults back to flash and - // decode slows to a crawl. Malloc'd weights are app RSS and stay put. - let use_mmap = if cfg!(mobile) { false } else { backend.supports_mmap() }; - let mut params = LlamaModelParams::default().with_use_mmap(use_mmap); - let use_gpu = match kind { - NativeModelKind::Chat => local_gpu_enabled(), - NativeModelKind::Embedding => embedding_gpu_enabled(), - }; - if use_gpu && backend.supports_gpu_offload() { - params = params.with_n_gpu_layers(999); - } else { - params = params - .with_n_gpu_layers(0) - .with_devices(&[]) - .map_err(|error| format!("Failed to select CPU model backend: {error}"))?; - } - let model = LlamaModel::load_from_file(backend, &path, ¶ms).map_err(|error| { - format!("Failed to load local model {}: {error}", model_label(&path)) - })?; - let loaded = LoadedNativeModel { path, model }; - match kind { - NativeModelKind::Chat => self.chat = Some(loaded), - NativeModelKind::Embedding => self.embedding = Some(loaded), - } - Ok(()) - } - - fn embed(&mut self, model_path: &Path, inputs: Vec) -> Cmd>> { - self.embed_with_progress(model_path, inputs, None) - } - - fn embed_with_progress( - &mut self, - model_path: &Path, - inputs: Vec, - progress: Option, - ) -> Cmd>> { - if inputs.is_empty() { - return Ok(Vec::new()); - } - - self.ensure_model(NativeModelKind::Embedding, model_path)?; - let backend = self - .backend - .as_ref() - .ok_or_else(|| "Local model backend is not initialized.".to_string())?; - let model = &self - .embedding - .as_ref() - .ok_or_else(|| "Local embedding model is not loaded.".to_string())? - .model; - let threads = auto_thread_count(); - let total = inputs.len(); - let mut embeddings = Vec::with_capacity(total); - let mut tokenized_inputs = Vec::with_capacity(total); - - if let Some(progress) = &progress { - progress.emit_message("Tokenizing chunks", 0, total); - } - - for input in inputs { - let tokens = model - .str_to_token(&input, AddBos::Always) - .map_err(|error| error.to_string())?; - if tokens.is_empty() { - return Err("Local embedding input produced no tokens.".to_string()); - } - tokenized_inputs.push(tokens); - } - - let max_sequences = if is_qwen3_embedding_model(model_path) { - 1 - } else { - embedding_batch_size().min(16) - }; - let max_batch_tokens = embedding_batch_token_limit(); - let mut input_index = 0; - let mut batches = Vec::new(); - - while input_index < tokenized_inputs.len() { - let mut batch_token_count = 0usize; - let mut batch_end = input_index; - - while batch_end < tokenized_inputs.len() - && batch_end - input_index < max_sequences - && (batch_token_count == 0 - || batch_token_count + tokenized_inputs[batch_end].len() <= max_batch_tokens) - { - batch_token_count += tokenized_inputs[batch_end].len(); - batch_end += 1; - } - - batches.push((input_index, batch_end, batch_token_count)); - input_index = batch_end; - } - - let max_batch_token_count = batches - .iter() - .map(|(_, _, batch_token_count)| *batch_token_count) - .max() - .unwrap_or_default(); - let max_batch_sequence_count = batches - .iter() - .map(|(batch_start, batch_end, _)| batch_end - batch_start) - .max() - .unwrap_or(1); - let n_ctx = embedding_context_tokens(max_batch_token_count); - if max_batch_token_count as u32 > n_ctx { - return Err(format!( - "Local embedding batch is too long for the embedding context: {} tokens exceeds {}.", - max_batch_token_count, n_ctx - )); - } - let n_batch = n_ctx.max(max_batch_token_count as u32).max(512); - let offload_embedding_ops = embedding_gpu_enabled(); - let ctx_params = LlamaContextParams::default() - .with_n_ctx(NonZeroU32::new(n_ctx)) - .with_n_seq_max(max_batch_sequence_count as u32) - .with_n_batch(n_batch) - .with_n_ubatch(n_batch) - .with_n_threads(threads) - .with_n_threads_batch(threads) - .with_embeddings(true) - .with_offload_kqv(offload_embedding_ops) - .with_op_offload(offload_embedding_ops) - .with_attention_type(embedding_attention_type(model_path)) - .with_pooling_type(embedding_pooling_type(model_path)); - let mut ctx = model - .new_context(backend, ctx_params) - .map_err(|error| error.to_string())?; - - for (batch_start, batch_end, batch_token_count) in batches { - let batch_sequence_count = batch_end - batch_start; - if let Some(progress) = &progress { - progress.emit_message( - format!( - "Embedding chunks {}-{batch_end} of {total}", - batch_start + 1 - ), - batch_start, - total, - ); - } - - ctx.clear_kv_cache(); - let mut batch = LlamaBatch::new(batch_token_count, batch_sequence_count as i32); - for (sequence_index, tokens) in - tokenized_inputs[batch_start..batch_end].iter().enumerate() - { - batch - .add_sequence(tokens, sequence_index as i32, false) - .map_err(|error| error.to_string())?; - } - // Qwen3 Embedding is decoder-style; the llama_encode path segfaults in - // llm_build_qwen3 on macOS with llama-cpp-2 0.1.146. - if qwen3_embedding_decode(model_path) { - ctx.decode(&mut batch).map_err(|error| error.to_string())?; - } else { - ctx.encode(&mut batch).map_err(|error| error.to_string())?; - } - - for sequence_index in 0..batch_sequence_count { - let embedding = ctx - .embeddings_seq_ith(sequence_index as i32) - .map_err(|error| error.to_string())?; - embeddings.push(normalize_embedding(embedding)); - } - if let Some(progress) = &progress { - progress.emit(batch_end, total); - } - } - - Ok(embeddings) - } - - #[cfg(desktop)] - fn warm_embedding_model(&mut self, model_path: &Path) -> Cmd<()> { - self.ensure_model(NativeModelKind::Embedding, model_path) - } - - fn complete_chat( - &mut self, - model_path: &Path, - messages: Vec, - max_tokens: usize, - temperature: f32, - cancel: &AtomicBool, - on_token: Option>, - mut on_status: Option>, - ) -> Cmd { - // The first ask pays for the multi-GB model load; on phone-class - // storage that is long enough to read as a hang without a status. - let needs_load = self - .chat - .as_ref() - .map(|loaded| loaded.path != canonical_model_path(model_path)) - .unwrap_or(true); - if needs_load { - if let Some(callback) = on_status.as_mut() { - callback(format!( - "Loading {} (first ask takes a moment)", - friendly_model_label(model_path) - )); - } - } - self.ensure_model(NativeModelKind::Chat, model_path)?; - let rendered = { - let model = &self - .chat - .as_ref() - .ok_or_else(|| "Local chat model is not loaded.".to_string())? - .model; - render_model_chat_prompt(model, &messages)? - }; - self.complete_loaded_prompt( - &rendered.prompt, - max_tokens, - temperature, - rendered.add_bos, - cancel, - on_token, - on_status, - ) - } - - #[allow(clippy::too_many_arguments)] - fn complete_loaded_prompt( - &mut self, - prompt: &str, - max_tokens: usize, - temperature: f32, - add_bos: AddBos, - cancel: &AtomicBool, - mut on_token: Option>, - mut on_status: Option>, - ) -> Cmd { - let backend = self - .backend - .as_ref() - .ok_or_else(|| "Local model backend is not initialized.".to_string())?; - let model = &self - .chat - .as_ref() - .ok_or_else(|| "Local chat model is not loaded.".to_string())? - .model; - let mut tokens = model - .str_to_token(prompt, add_bos) - .map_err(|error| error.to_string())?; - if tokens.is_empty() { - return Err("Local chat prompt produced no tokens.".to_string()); - } - - let n_ctx = chat_context_tokens(); - let max_prompt_tokens = - n_ctx.saturating_sub((max_tokens as u32).min(1024)).max(512) as usize; - if tokens.len() > max_prompt_tokens { - tokens = tokens[tokens.len() - max_prompt_tokens..].to_vec(); - } - let n_batch = (chat_batch_token_limit() as u32).min(n_ctx).max(512); - // Mobile: small micro-batches keep the compute buffer allocation - // phone-sized and make prefill progress tick in visible steps. - let n_ubatch = if cfg!(mobile) { - n_batch.min(512) - } else { - n_batch.min(2048) - } - .max(512); - let threads = auto_thread_count(); - let offload_ops = local_gpu_enabled(); - let ctx_params = LlamaContextParams::default() - .with_n_ctx(NonZeroU32::new(n_ctx)) - .with_n_batch(n_batch) - .with_n_ubatch(n_ubatch) - .with_n_threads(threads) - .with_n_threads_batch(threads) - .with_offload_kqv(offload_ops) - .with_op_offload(offload_ops); - let mut ctx = model - .new_context(backend, ctx_params) - .map_err(|error| error.to_string())?; - - let last_prompt_index = tokens.len().saturating_sub(1); - let prompt_batch_limit = if cfg!(mobile) { - (n_batch as usize).min(512) - } else { - n_batch as usize - }; - let total_prompt_tokens = tokens.len(); - let mut prompt_cursor = 0usize; - let mut sample_index = 0; - while prompt_cursor < tokens.len() { - if cancel.load(AtomicOrdering::Relaxed) { - return Err("Generation stopped.".to_string()); - } - if let Some(callback) = on_status.as_mut() { - let percent = prompt_cursor * 100 / total_prompt_tokens; - callback(format!("Reading context {percent}%")); - } - let prompt_end = (prompt_cursor + prompt_batch_limit).min(tokens.len()); - let mut prompt_batch = LlamaBatch::new(prompt_end - prompt_cursor, 1); - for (offset, token) in tokens[prompt_cursor..prompt_end].iter().enumerate() { - let index = prompt_cursor + offset; - prompt_batch - .add(*token, index as i32, &[0], index == last_prompt_index) - .map_err(|error| error.to_string())?; - } - ctx.decode(&mut prompt_batch) - .map_err(|error| error.to_string())?; - if prompt_end == tokens.len() { - sample_index = prompt_batch.n_tokens() - 1; - } - prompt_cursor = prompt_end; - } - if let Some(callback) = on_status.as_mut() { - callback("Generating answer".to_string()); - } - - let mut sampler = LlamaSampler::chain_simple([ - LlamaSampler::top_k(DEFAULT_TOP_K), - LlamaSampler::top_p(DEFAULT_TOP_P, 1), - LlamaSampler::temp(temperature), - LlamaSampler::dist(0xA371_2026), - ]); - let mut decoder = UTF_8.new_decoder(); - let mut output = String::new(); - let mut generated_tokens = 0usize; - let mut streamed_len = 0usize; - let mut batch = LlamaBatch::new(1, 1); - let mut position = tokens.len() as i32; - - for _ in 0..max_tokens { - if cancel.load(AtomicOrdering::Relaxed) { - break; - } - let token = sampler.sample(&ctx, sample_index); - if model.is_eog_token(token) { - break; - } - generated_tokens = generated_tokens.saturating_add(1); - let piece = model - .token_to_piece(token, &mut decoder, true, None) - .map_err(|error| error.to_string())?; - output.push_str(&piece); - if contains_stop_marker(&output) { - break; - } - if let Some(on_token) = on_token.as_mut() { - let safe_end = stream_safe_len(&output); - if safe_end > streamed_len { - on_token(&output[streamed_len..safe_end]); - streamed_len = safe_end; - } - } - - batch.clear(); - batch - .add(token, position, &[0], true) - .map_err(|error| error.to_string())?; - ctx.decode(&mut batch).map_err(|error| error.to_string())?; - sample_index = batch.n_tokens() - 1; - position += 1; - } - - if output.trim().is_empty() && cancel.load(AtomicOrdering::Relaxed) { - return Err("Generation stopped.".to_string()); - } - - Ok(ChatCompletion { - text: output, - generated_tokens, - }) - } -} - -#[derive(Clone)] -struct ModelDownloadSpec { - id: &'static str, - label: &'static str, - repository: &'static str, - revision: &'static str, - filename: &'static str, - destination: PathBuf, - expected_bytes: u64, -} - -impl ModelDownloadSpec { - fn source_url(&self) -> String { - format!( - "https://huggingface.co/{}/resolve/{}/{}?download=true", - self.repository, self.revision, self.filename - ) - } -} - -async fn download_managed_models( - app: &AppHandle, - state: &State<'_, Backend>, - input: DownloadModelsInput, -) -> Cmd<()> { - let specs = selected_model_downloads(&state.paths, &input)?; - let hf_token = input - .hf_token - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .or_else(huggingface_token); - let overall_total = specs - .iter() - .map(|spec| spec.expected_bytes) - .reduce(|first, second| first.saturating_add(second)); - let mut overall_downloaded = 0u64; - - for spec in &specs { - if let Some(existing_bytes) = - completed_model_bytes(&spec.destination, spec.expected_bytes).await - { - overall_downloaded = overall_downloaded.saturating_add(existing_bytes); - emit_model_download_progress( - app, - spec, - "skipped", - existing_bytes, - Some(spec.expected_bytes), - overall_downloaded, - overall_total, - Some("Already installed".to_string()), - ); - continue; - } - - emit_model_download_progress( - app, - spec, - "queued", - 0, - Some(spec.expected_bytes), - overall_downloaded, - overall_total, - Some("Preparing download".to_string()), - ); - - match download_model_file( - app, - &state.client, - spec, - overall_downloaded, - overall_total, - hf_token.as_deref(), - ) - .await - { - Ok(downloaded_bytes) => { - overall_downloaded = overall_downloaded.saturating_add(downloaded_bytes); - emit_model_download_progress( - app, - spec, - "complete", - downloaded_bytes, - Some(downloaded_bytes), - overall_downloaded, - overall_total, - Some("Installed".to_string()), - ); - } - Err(error) => { - emit_model_download_progress( - app, - spec, - "error", - 0, - Some(spec.expected_bytes), - overall_downloaded, - overall_total, - Some(error.clone()), - ); - return Err(error); - } - } - } - - persist_downloaded_model_selection(&state.paths, &specs).await -} - -fn selected_model_downloads( - paths: &DataPaths, - input: &DownloadModelsInput, -) -> Cmd> { - let mut specs = vec![managed_model_spec(paths, "mist")?]; - let mut selected = HashSet::new(); - - for model in &input.chat_models { - let normalized = model.trim().to_lowercase(); - if normalized.is_empty() { - continue; - } - if !selected.insert(normalized.clone()) { - continue; - } - specs.push(managed_model_spec(paths, &normalized)?); - } - - Ok(specs) -} - -fn managed_model_spec(paths: &DataPaths, id: &str) -> Cmd { - match id { - "mist" => Ok(ModelDownloadSpec { - id: "mist", - label: "AiON MiST", - repository: "Qwen/Qwen3-Embedding-0.6B-GGUF", - revision: "370f27d7550e0def9b39c1f16d3fbaa13aa67728", - filename: "Qwen3-Embedding-0.6B-Q8_0.gguf", - destination: paths - .models_path - .join("embeddings") - .join("Qwen3-Embedding-0.6B-GGUF") - .join("Qwen3-Embedding-0.6B-Q8_0.gguf"), - expected_bytes: 639_150_592, - }), - "lite" => Ok(ModelDownloadSpec { - id: "lite", - label: "AiON LiTE", - repository: "google/gemma-4-E2B-it-qat-q4_0-gguf", - revision: "1894d1fc0a19d86697abd40483f5983c867df03f", - filename: "gemma-4-E2B_q4_0-it.gguf", - destination: paths - .models_path - .join("chat") - .join("gemma-4-E2B-it-qat-q4_0-gguf") - .join("gemma-4-E2B_q4_0-it.gguf"), - expected_bytes: 3_349_514_112, - }), - "wise" => Ok(ModelDownloadSpec { - id: "wise", - label: "AiON WiSE", - repository: "google/gemma-4-E4B-it-qat-q4_0-gguf", - revision: "bb3b92e6f031fa438b409f898dd9f14f499a0cb0", - filename: "gemma-4-E4B_q4_0-it.gguf", - destination: paths - .models_path - .join("chat") - .join("gemma-4-E4B-it-qat-q4_0-gguf") - .join("gemma-4-E4B_q4_0-it.gguf"), - expected_bytes: 5_154_939_136, - }), - _ => Err(format!("Unknown AiON model selection: {id}")), - } -} - -async fn completed_model_bytes(path: &Path, expected_bytes: u64) -> Option { - let metadata = tokio::fs::metadata(path).await.ok()?; - let len = metadata.len(); - if metadata.is_file() && len == expected_bytes { - Some(len) - } else { - None - } -} - -async fn download_model_file( - app: &AppHandle, - client: &Client, - spec: &ModelDownloadSpec, - overall_base_bytes: u64, - overall_total_bytes: Option, - hf_token: Option<&str>, -) -> Cmd { - let parent = spec - .destination - .parent() - .ok_or_else(|| format!("Invalid model destination: {}", spec.destination.display()))?; - tokio::fs::create_dir_all(parent).await.map_err(|error| { - format!( - "Could not create model directory {}: {error}", - parent.display() - ) - })?; - - let temp_path = spec - .destination - .with_file_name(format!("{}.part", spec.filename)); - let _ = tokio::fs::remove_file(&temp_path).await; - - let mut request = client.get(spec.source_url()); - if let Some(token) = hf_token { - request = request.bearer_auth(token); - } - - let mut response = request - .send() - .await - .map_err(|error| format!("Could not reach Hugging Face for {}: {error}", spec.label))?; - let status = response.status(); - if !status.is_success() { - return Err(huggingface_download_error(spec, status.as_u16())); - } - - let total_bytes = response.content_length().or(Some(spec.expected_bytes)); - let mut file = tokio::fs::File::create(&temp_path).await.map_err(|error| { - format!( - "Could not create temporary model file {}: {error}", - temp_path.display() - ) - })?; - let mut downloaded_bytes = 0u64; - let mut last_emit = Instant::now(); - - emit_model_download_progress( - app, - spec, - "downloading", - downloaded_bytes, - total_bytes, - overall_base_bytes, - overall_total_bytes, - Some("Downloading from Hugging Face".to_string()), - ); - - while let Some(chunk) = response - .chunk() - .await - .map_err(|error| format!("Download interrupted for {}: {error}", spec.label))? - { - file.write_all(&chunk) - .await - .map_err(|error| format!("Could not write {}: {error}", spec.filename))?; - downloaded_bytes = downloaded_bytes.saturating_add(chunk.len() as u64); - - if last_emit.elapsed() >= Duration::from_millis(160) { - emit_model_download_progress( - app, - spec, - "downloading", - downloaded_bytes, - total_bytes, - overall_base_bytes.saturating_add(downloaded_bytes), - overall_total_bytes, - None, - ); - last_emit = Instant::now(); - } - } - - file.flush() - .await - .map_err(|error| format!("Could not finalize {}: {error}", spec.filename))?; - drop(file); - - if let Some(total) = total_bytes { - if downloaded_bytes != total { - let _ = tokio::fs::remove_file(&temp_path).await; - return Err(format!( - "Downloaded {} bytes for {}, expected {}.", - downloaded_bytes, spec.label, total - )); - } - } - - let _ = tokio::fs::remove_file(&spec.destination).await; - tokio::fs::rename(&temp_path, &spec.destination) - .await - .map_err(|error| { - format!( - "Could not move {} into {}: {error}", - temp_path.display(), - spec.destination.display() - ) - })?; - - Ok(downloaded_bytes) -} - -async fn persist_downloaded_model_selection( - paths: &DataPaths, - specs: &[ModelDownloadSpec], -) -> Cmd<()> { - let mut settings = load_settings(&paths.settings_path).await?; - if let Some(embedding) = specs.iter().find(|spec| spec.id == "mist") { - settings.local_model.embedding_model = Some(embedding.destination.display().to_string()); - } - if let Some(chat) = specs - .iter() - .rev() - .find(|spec| spec.id == "lite" || spec.id == "wise") - { - settings.local_model.chat_model = Some(chat.destination.display().to_string()); - } - save_json(&paths.settings_path, &settings).await -} - -fn huggingface_token() -> Option { - [ - HF_TOKEN_ENV, - HUGGINGFACE_HUB_TOKEN_ENV, - HUGGING_FACE_HUB_TOKEN_ENV, - ] - .iter() - .find_map(|var| env::var(var).ok()) - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) -} - -fn huggingface_download_error(spec: &ModelDownloadSpec, status: u16) -> String { - let auth_hint = if status == 401 || status == 403 { - format!( - " Accept the model terms on Hugging Face, then paste a Hugging Face read token in setup. Advanced users can also launch ÆTHER with {HF_TOKEN_ENV} or {HUGGINGFACE_HUB_TOKEN_ENV} set." - ) - } else { - String::new() - }; - format!( - "Could not download {} from official Hugging Face source {}/{} ({status}).{}", - spec.label, spec.repository, spec.filename, auth_hint - ) -} - -fn emit_model_download_progress( - app: &AppHandle, - spec: &ModelDownloadSpec, - status: &str, - downloaded_bytes: u64, - total_bytes: Option, - overall_downloaded_bytes: u64, - overall_total_bytes: Option, - message: Option, -) { - let _ = app.emit( - AETHER_MODEL_DOWNLOAD_PROGRESS_EVENT, - ModelDownloadProgress { - id: spec.id.to_string(), - label: spec.label.to_string(), - filename: spec.filename.to_string(), - status: status.to_string(), - downloaded_bytes, - total_bytes, - overall_downloaded_bytes, - overall_total_bytes, - message, - }, - ); -} - -fn model_catalog(paths: &DataPaths, settings: &LocalModelSettings) -> ModelCatalog { - let mut errors = Vec::new(); - let model_dirs = [ - paths.models_path.clone(), - paths.models_path.join("chat"), - paths.models_path.join("embeddings"), - ]; - for dir in &model_dirs { - if let Err(error) = fs::create_dir_all(dir) { - errors.push(format!( - "Could not create model directory {}: {error}", - dir.display() - )); - } - } - - let mut models = Vec::new(); - collect_gguf_models(&paths.models_path, &mut models); - if let Ok(dir) = env::var(AETHER_MODEL_DIR_ENV) { - let dir = PathBuf::from(dir); - collect_gguf_models(&dir, &mut models); - } - for var in [AETHER_CHAT_MODEL_ENV, AETHER_EMBEDDING_MODEL_ENV] { - match env_model_path(var) { - Ok(Some(path)) => models.push(path), - Ok(None) => {} - Err(error) => errors.push(error), - } - } - for (value, embedding) in [ - (settings.chat_model.as_deref(), false), - (settings.embedding_model.as_deref(), true), - ] { - if let Some(path) = value.and_then(|value| selected_direct_model_path(value, embedding)) { - models.push(path); - } - } - - models = dedupe_model_paths(models); - let embedding_model = pick_embedding_model(&models, settings); - let chat_model = pick_chat_model(&models, settings); - if models.is_empty() { - errors.push(format!( - "No local models found. Use Model Setup to install AiON MiST/Qwen3 Embedding and Gemma 4, add compatible GGUF models to {}, or set {AETHER_MODEL_DIR_ENV}.", - paths.models_path.display() - )); - } else { - if embedding_model.is_none() { - errors.push(format!( - "No embedding model selected. Put Qwen3 Embedding or another embedding GGUF in {} or set {AETHER_EMBEDDING_MODEL_ENV}.", - paths.models_path.join("embeddings").display() - )); - } - if chat_model.is_none() { - errors.push(format!( - "No chat GGUF selected. Put a Gemma chat model in {} or set {AETHER_CHAT_MODEL_ENV}.", - paths.models_path.join("chat").display() - )); - } - } - - ModelCatalog { - models, - chat_model, - embedding_model, - error: if errors.is_empty() { - None - } else { - Some(errors.join(" ")) - }, - } -} - -fn selected_direct_model_path(value: &str, embedding: bool) -> Option { - let value = value.trim(); - if value.is_empty() { - return None; - } - let path = PathBuf::from(value); - if selected_model_matches_kind(&path, embedding) { - Some(canonical_model_path(&path)) - } else { - None - } -} - -fn collect_gguf_models(root: &Path, models: &mut Vec) { - let mut stack = vec![(root.to_path_buf(), 0usize)]; - while let Some((dir, depth)) = stack.pop() { - if depth > 4 { - continue; - } - let Ok(entries) = fs::read_dir(&dir) else { - continue; - }; - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - stack.push((path, depth + 1)); - } else if is_gguf_model(&path) { - models.push(path); - } - } - } -} - -fn default_models_path(app_data_dir: &Path) -> PathBuf { - // The repo-relative dev path is a compile-time string from the build - // machine; on a phone it would point at a nonexistent host filesystem. - if cfg!(all(debug_assertions, desktop)) { - project_models_path() - } else { - app_data_dir.join("aether-models") - } -} - -fn project_models_path() -> PathBuf { - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .map(|path| path.join("aether-models")) - .unwrap_or_else(|| PathBuf::from("aether-models")) -} - -fn dedupe_model_paths(models: Vec) -> Vec { - let mut seen = HashSet::new(); - let mut deduped = Vec::new(); - for path in models { - let path = canonical_model_path(&path); - let key = path.display().to_string(); - if seen.insert(key) { - deduped.push(path); - } - } - deduped.sort_by_key(|path| model_label(path).to_lowercase()); - deduped -} - -fn env_model_path(var: &str) -> Cmd> { - let Ok(value) = env::var(var) else { - return Ok(None); - }; - let path = PathBuf::from(value.trim()); - let valid = match var { - AETHER_CHAT_MODEL_ENV => is_chat_model(&path), - AETHER_EMBEDDING_MODEL_ENV => is_embedding_model(&path), - _ => is_gguf_model(&path), - }; - if valid { - Ok(Some(path)) - } else { - Err(format!( - "{var} does not point to an existing local model: {}", - path.display() - )) - } -} - -fn pick_embedding_model(models: &[PathBuf], settings: &LocalModelSettings) -> Option { - if let Ok(Some(path)) = env_model_path(AETHER_EMBEDDING_MODEL_ENV) { - return Some(canonical_model_path(&path)); - } - if let Some(model) = settings - .embedding_model - .as_deref() - .and_then(|value| pick_selected_model(models, value, true)) - { - return Some(model); - } - models - .iter() - .filter(|path| is_embedding_model(path)) - .max_by_key(|path| embedding_model_score(path)) - .cloned() -} - -fn pick_chat_model(models: &[PathBuf], settings: &LocalModelSettings) -> Option { - if let Ok(Some(path)) = env_model_path(AETHER_CHAT_MODEL_ENV) { - return Some(canonical_model_path(&path)); - } - if let Some(model) = settings - .chat_model - .as_deref() - .and_then(|value| pick_selected_model(models, value, false)) - { - return Some(model); - } - pick_model_by_hints(models, &PREFERRED_CHAT_MODEL_HINTS, false).or_else(|| { - models - .iter() - .find(|path| !is_embedding_model_name(path)) - .cloned() - }) -} - -fn pick_selected_model(models: &[PathBuf], value: &str, embedding: bool) -> Option { - let value = value.trim(); - if value.is_empty() { - return None; - } - let direct = PathBuf::from(value); - if selected_model_matches_kind(&direct, embedding) { - return Some(canonical_model_path(&direct)); - } - let normalized = value.to_lowercase(); - models - .iter() - .find(|path| { - let label = model_label(path); - path_to_model_value(path) == value - || label == value - || strip_gguf_extension(&label) == value - || label.to_lowercase().contains(&normalized) - }) - .filter(|path| selected_model_matches_kind(path, embedding)) - .cloned() -} - -fn pick_model_by_hints(models: &[PathBuf], hints: &[&str], embedding: bool) -> Option { - for hint in hints { - let hint = hint.to_lowercase(); - if let Some(model) = models.iter().find(|path| { - let label = model_label(path).to_lowercase(); - label.contains(&hint) - && if embedding { - is_embedding_model(path) - } else { - is_chat_model(path) - } - }) { - return Some(model.clone()); - } - } - None -} - -fn embedding_model_score(path: &Path) -> i32 { - let label = model_label(path).to_lowercase(); - let mut score = 0; - if is_gguf_model(path) { - score += 1_000; - } - if label.contains("qwen3-embedding") { - score += 650; - } - if label.contains("bf16") { - score += 400; - } else if label.contains("f16") { - score += 300; - } else if label.contains("q8") { - score += 150; - } - score -} - -fn embedding_pooling_type(path: &Path) -> LlamaPoolingType { - if is_qwen3_embedding_model(path) { - LlamaPoolingType::Last - } else { - LlamaPoolingType::Mean - } -} - -fn embedding_attention_type(path: &Path) -> LlamaAttentionType { - if is_qwen3_embedding_model(path) { - LlamaAttentionType::Causal - } else { - LlamaAttentionType::Unspecified - } -} - -fn is_qwen3_embedding_model(path: &Path) -> bool { - let label = model_label(path).to_lowercase(); - label.contains("qwen3-embedding") -} - -fn qwen3_embedding_decode(path: &Path) -> bool { - is_qwen3_embedding_model(path) -} - -fn is_gguf_model(path: &Path) -> bool { - path.is_file() - && !is_mmproj_model(path) - && path - .extension() - .and_then(|extension| extension.to_str()) - .is_some_and(|extension| extension.eq_ignore_ascii_case("gguf")) -} - -fn is_chat_model(path: &Path) -> bool { - is_gguf_model(path) && !is_embedding_model_name(path) -} - -fn selected_model_matches_kind(path: &Path, embedding: bool) -> bool { - if embedding { - is_embedding_model(path) - } else { - is_chat_model(path) - } -} - -fn is_embedding_model(path: &Path) -> bool { - is_gguf_model(path) && is_embedding_model_name(path) -} - -fn is_embedding_model_name(path: &Path) -> bool { - let label = model_label(path).to_lowercase(); - label.contains("embed") || label.contains("embedding") -} - -fn is_mmproj_model(path: &Path) -> bool { - model_label(path).to_lowercase().contains("mmproj") -} - -fn canonical_model_path(path: &Path) -> PathBuf { - fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) -} - -fn path_to_model_value(path: &PathBuf) -> String { - path.display().to_string() -} - -fn model_label(path: &Path) -> String { - path.file_name() - .and_then(|name| name.to_str()) - .map(strip_gguf_extension) - .unwrap_or_else(|| path.display().to_string()) -} - -/// Product name for user-facing status text. Filenames must stay in sync with -/// `managed_model_spec`; anything unmanaged falls back to its cleaned filename. -fn friendly_model_label(path: &Path) -> String { - match path.file_name().and_then(|name| name.to_str()) { - Some("gemma-4-E2B_q4_0-it.gguf") => "AiON LiTE".to_string(), - Some("gemma-4-E4B_q4_0-it.gguf") => "AiON WiSE".to_string(), - Some("Qwen3-Embedding-0.6B-Q8_0.gguf") => "AiON MiST".to_string(), - _ => model_label(path), - } -} - -fn strip_gguf_extension(value: &str) -> String { - value - .strip_suffix(".gguf") - .or_else(|| value.strip_suffix(".GGUF")) - .unwrap_or(value) - .to_string() -} - -fn chat_context_tokens() -> u32 { - // Phones get half the desktop window: the KV cache plus compute buffers - // for 6k context put a multi-GB model into zram-thrashing territory, - // which reads as a silent hang during prefill. - let default = if cfg!(mobile) { - 3072 - } else { - DEFAULT_CHAT_CONTEXT_TOKENS - }; - env::var(AETHER_LLM_CONTEXT_ENV) - .ok() - .and_then(|value| value.parse::().ok()) - .unwrap_or(default) - .clamp(1024, 65_536) -} - -fn chat_batch_token_limit() -> usize { - env::var(AETHER_LLM_BATCH_TOKENS_ENV) - .ok() - .and_then(|value| value.parse::().ok()) - .unwrap_or(DEFAULT_CHAT_BATCH_TOKENS) - .clamp(512, 8192) -} - -fn local_gpu_enabled() -> bool { - env_flag_enabled(AETHER_LLM_GPU_ENV, cfg!(target_os = "macos")) -} - -fn embedding_gpu_enabled() -> bool { - env_flag_enabled(AETHER_EMBED_GPU_ENV, false) -} - -fn env_flag_enabled(name: &str, default: bool) -> bool { - env::var(name).ok().map_or(default, |value| { - matches!(value.to_lowercase().as_str(), "1" | "true" | "yes" | "on") - }) -} - -fn embedding_batch_size() -> usize { - env::var(AETHER_EMBED_BATCH_ENV) - .ok() - .and_then(|value| value.parse::().ok()) - .unwrap_or(DEFAULT_EMBEDDING_BATCH_SIZE) - .clamp(1, 24) -} - -fn embedding_batch_token_limit() -> usize { - env::var(AETHER_EMBED_BATCH_TOKENS_ENV) - .ok() - .and_then(|value| value.parse::().ok()) - .unwrap_or(DEFAULT_EMBEDDING_BATCH_TOKENS) - .clamp(512, 8192) -} - -fn embedding_context_tokens(input_tokens: usize) -> u32 { - let needed = input_tokens.saturating_add(16).min(u32::MAX as usize) as u32; - DEFAULT_EMBEDDING_CONTEXT_TOKENS.max(needed).min(8192) -} - -fn auto_thread_count() -> i32 { - // Mobile keeps one core free instead of two: recent flagships (like the - // all-big-core Snapdragon 8 Elite) have no little cores to avoid, prefill - // is compute-bound, and the UI sits idle while AiON works. - let reserve = if cfg!(mobile) { 1 } else { 2 }; - std::thread::available_parallelism() - .map(|threads| threads.get().saturating_sub(reserve).clamp(2, 12) as i32) - .unwrap_or(6) -} - -fn normalize_embedding(values: &[f32]) -> Vec { - let norm = values - .iter() - .map(|value| (*value as f64) * (*value as f64)) - .sum::() - .sqrt(); - if norm <= f64::EPSILON { - return values.to_vec(); - } - values - .iter() - .map(|value| (*value as f64 / norm) as f32) - .collect() -} - -// Phones prefill on CPU, so prompt length is the ask latency. The mobile -// budget (5 sources x ~1100 chars + system + question) fits the 2048-token -// prompt window, where desktop's 8 full chunks would overflow it and get -// front-truncated — losing the system message and top-ranked sources while -// still paying full prefill cost. -fn chat_citation_limit() -> usize { - if cfg!(mobile) { - 5 - } else { - 8 + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + pub struct EvalPayload<'a> { + pub tab_id: &'a str, + pub script: String, } -} -fn chat_snippet_char_limit() -> usize { - if cfg!(mobile) { - 1100 - } else { - usize::MAX + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + pub struct FindPayload<'a> { + pub tab_id: &'a str, + pub query: Option<&'a str>, + pub action: &'a str, } } -fn build_chat_messages( - prompt: &str, - citations: &[SearchResult], - history: &[ConversationTurn], -) -> Vec { - let snippet_limit = chat_snippet_char_limit(); - let context_block = citations - .iter() - .enumerate() - .map(|(index, item)| { - let mut source_text = strip_numeric_bracket_markers(&item.text); - if source_text.chars().count() > snippet_limit { - source_text = source_text.chars().take(snippet_limit).collect(); - } - format!( - "[{}] {}\nURL: {}\nCollection: {}\n{}", - index + 1, - item.title, - item.url, - item.collection_id, - source_text - ) +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + let builder = tauri::Builder::default(); + #[cfg(desktop)] + let builder = builder + .menu(|app| { + let menu = Menu::new(app)?; + let focus_address_item = MenuItem::with_id( + app, + AETHER_FOCUS_ADDRESS_MENU_ID, + "Focus Address Bar", + true, + Some("CmdOrCtrl+L"), + )?; + let new_tab_item = MenuItem::with_id( + app, + AETHER_NEW_TAB_MENU_ID, + "New Tab", + true, + Some("CmdOrCtrl+T"), + )?; + let find_item = MenuItem::with_id( + app, + AETHER_FIND_MENU_ID, + "Find in Page", + true, + Some("CmdOrCtrl+F"), + )?; + let open_dashboard_item = MenuItem::with_id( + app, + AETHER_OPEN_DASHBOARD_MENU_ID, + "Open Dashboard", + true, + Some("CmdOrCtrl+1"), + )?; + let open_ice_item = MenuItem::with_id( + app, + AETHER_OPEN_ICE_MENU_ID, + "Open iCE", + true, + Some("CmdOrCtrl+2"), + )?; + let open_browser_item = MenuItem::with_id( + app, + AETHER_OPEN_BROWSER_MENU_ID, + "Open Browser", + true, + Some("CmdOrCtrl+3"), + )?; + let toggle_aion_item = MenuItem::with_id( + app, + AETHER_TOGGLE_AION_MENU_ID, + "Toggle AiON", + true, + Some("CmdOrCtrl+Shift+A"), + )?; + let capture_page_item = MenuItem::with_id( + app, + AETHER_CAPTURE_PAGE_MENU_ID, + "Capture Current Page", + true, + Some("CmdOrCtrl+Shift+C"), + )?; + let shortcuts_menu = Submenu::with_items( + app, + "Shortcuts", + true, + &[ + &focus_address_item, + &new_tab_item, + &find_item, + &open_dashboard_item, + &open_ice_item, + &open_browser_item, + &toggle_aion_item, + &capture_page_item, + ], + )?; + // Standard Edit submenu. Its predefined items carry the native key + // equivalents (Cmd/Ctrl+A/C/V/X and undo/redo), which is what wires up + // select-all/copy/paste in the address bar and other text fields. An + // empty Menu::new has no Edit menu, so those shortcuts would do nothing. + let edit_menu = Submenu::with_items( + app, + "Edit", + true, + &[ + &PredefinedMenuItem::undo(app, None)?, + &PredefinedMenuItem::redo(app, None)?, + &PredefinedMenuItem::separator(app)?, + &PredefinedMenuItem::cut(app, None)?, + &PredefinedMenuItem::copy(app, None)?, + &PredefinedMenuItem::paste(app, None)?, + &PredefinedMenuItem::select_all(app, None)?, + ], + )?; + menu.append(&edit_menu)?; + menu.append(&shortcuts_menu)?; + Ok(menu) }) - .collect::>() - .join("\n\n"); - let context = if context_block.is_empty() { - "No stored context was retrieved." - } else { - &context_block - }; - // Phones decode on CPU, so every generated token is user-visible latency: - // steer the model toward tight answers instead of hard-truncating them. - let brevity = if cfg!(mobile) { - " Keep answers concise: a few sentences or a short list unless the question needs more." - } else { - "" - }; - let mut messages = vec![ChatPromptMessage { - role: "system", - content: format!( - "You are Æther, a private local research assistant. Answer only from the supplied local collection context. If the context is insufficient, say what is missing. Cite sources only with Æther source numbers [1] through [{}]. Do not copy bracketed reference numbers from webpage text.{brevity}", - citations.len().max(1) - ), - }]; - - // Prior turns come first so "what about that?" resolves, but each answer is - // condensed: the citations for *this* question must stay the dominant context. - for turn in history { - messages.push(ChatPromptMessage { - role: "user", - content: turn.prompt.clone(), - }); - messages.push(ChatPromptMessage { - role: "assistant", - content: condense_history_answer(&turn.answer), - }); - } - - messages.push(ChatPromptMessage { - role: "user", - content: format!("Local collection context:\n{context}\n\nQuestion: {prompt}"), - }); - messages -} - -// Strips citation markers and clips length. Replaying markers from an old answer -// would let the model reuse source numbers that no longer refer to anything. -fn condense_history_answer(answer: &str) -> String { - let cleaned = strip_numeric_bracket_markers(answer); - let trimmed = cleaned.trim(); - if trimmed.chars().count() <= PROMPT_HISTORY_ANSWER_CHARS { - return trimmed.to_string(); - } - let mut condensed = trimmed - .chars() - .take(PROMPT_HISTORY_ANSWER_CHARS) - .collect::(); - condensed.push('…'); - condensed -} - -fn render_model_chat_prompt( - model: &LlamaModel, - messages: &[ChatPromptMessage], -) -> Cmd { - let template = match model.chat_template(None) { - Ok(template) => template, - Err(_) => { - return Ok(RenderedChatPrompt { - prompt: fallback_chat_prompt(messages), - add_bos: AddBos::Never, - }) - } - }; - let chat = messages - .iter() - .map(|message| LlamaChatMessage::new(message.role.to_string(), message.content.clone())) - .collect::, _>>() - .map_err(|error| error.to_string())?; - match model.apply_chat_template(&template, &chat, true) { - Ok(prompt) => Ok(RenderedChatPrompt { - prompt, - add_bos: AddBos::Never, - }), - Err(_) => Ok(RenderedChatPrompt { - prompt: fallback_chat_prompt(messages), - add_bos: AddBos::Never, - }), - } -} - -fn fallback_chat_prompt(messages: &[ChatPromptMessage]) -> String { - let mut prompt = String::from(""); - let mut system_messages = Vec::new(); - - for message in messages { - match message.role { - "system" | "developer" => { - system_messages.push(message.content.trim().to_string()); + .on_menu_event(|app, event| match event.id().as_ref() { + AETHER_FIND_MENU_ID => { + let _ = app.emit(AETHER_FIND_REQUESTED_EVENT, ()); } - "assistant" => { - prompt.push_str("<|turn>model\n"); - prompt.push_str(message.content.trim()); - prompt.push_str("\n"); + AETHER_FOCUS_ADDRESS_MENU_ID => { + let _ = app.emit(AETHER_SHORTCUT_EVENT, "focus-address"); } - "user" => { - if !system_messages.is_empty() { - prompt.push_str("<|turn>system\n"); - prompt.push_str(&system_messages.join("\n\n")); - prompt.push_str("\n"); - system_messages.clear(); - } - prompt.push_str("<|turn>user\n"); - prompt.push_str(message.content.trim()); - prompt.push_str("\n"); + AETHER_NEW_TAB_MENU_ID => { + let _ = app.emit(AETHER_SHORTCUT_EVENT, "new-tab"); } - role => { - prompt.push_str("<|turn>"); - prompt.push_str(role); - prompt.push('\n'); - prompt.push_str(message.content.trim()); - prompt.push_str("\n"); + AETHER_OPEN_DASHBOARD_MENU_ID => { + let _ = app.emit(AETHER_SHORTCUT_EVENT, "open-dashboard"); } - } - } - - if !system_messages.is_empty() { - prompt.push_str("<|turn>system\n"); - prompt.push_str(&system_messages.join("\n\n")); - prompt.push_str("\n"); - } - - prompt.push_str("<|turn>model\n"); - prompt -} - -// Streaming deltas hold back a short tail starting at the most recent '<' so a -// stop marker arriving across several tokens is never shown to the user. -fn stream_safe_len(output: &str) -> usize { - let tail_start = output.len().saturating_sub(18); - let Some((boundary, _)) = output.char_indices().find(|(index, _)| *index >= tail_start) else { - return output.len(); - }; - match output[boundary..].rfind('<') { - Some(position) => boundary + position, - None => output.len(), - } -} - -fn contains_stop_marker(output: &str) -> bool { - output.contains("") - || output.contains("") - || output.contains("") - || output.contains("<|turn>") - || output.contains("<|eot_id|>") - || output.contains("<|end|>") -} - -fn clean_model_output(output: &str) -> String { - let mut cleaned = output.to_string(); - for marker in [ - "", - "model", - "assistant", - "", - "", - "<|turn>model", - "<|turn>assistant", - "<|turn>user", - "<|turn>system", - "<|turn>", - "<|eot_id|>", - "<|end|>", - ] { - cleaned = cleaned.replace(marker, ""); - } - cleaned.trim().to_string() -} - -fn normalize_answer_citations(answer: &str, citation_count: usize) -> String { - tidy_citation_spacing(&rewrite_numeric_bracket_markers( - answer, - citation_count, - true, - )) -} - -fn strip_numeric_bracket_markers(text: &str) -> String { - rewrite_numeric_bracket_markers(text, 0, false) -} - -fn rewrite_numeric_bracket_markers(text: &str, citation_count: usize, keep_valid: bool) -> String { - let mut rewritten = String::with_capacity(text.len()); - let mut cursor = 0usize; - - while let Some(relative_start) = text[cursor..].find('[') { - let start = cursor + relative_start; - let Some(relative_end) = text[start + 1..].find(']') else { - break; - }; - let end = start + 1 + relative_end; - let inner = &text[start + 1..end]; - let Some(numbers) = parse_numeric_citation_marker(inner) else { - rewritten.push_str(&text[cursor..=start]); - cursor = start + 1; - continue; - }; - - rewritten.push_str(&text[cursor..start]); - if keep_valid { - let valid = numbers - .into_iter() - .filter(|number| *number > 0 && *number <= citation_count) - .map(|number| number.to_string()) - .collect::>(); - if !valid.is_empty() { - rewritten.push('['); - rewritten.push_str(&valid.join(", ")); - rewritten.push(']'); + AETHER_OPEN_ICE_MENU_ID => { + let _ = app.emit(AETHER_SHORTCUT_EVENT, "open-ice"); } - } - cursor = end + 1; - } - - rewritten.push_str(&text[cursor..]); - rewritten -} - -fn parse_numeric_citation_marker(value: &str) -> Option> { - if value.trim().is_empty() - || !value.chars().all(|character| { - character.is_ascii_digit() || character == ',' || character.is_whitespace() - }) - { - return None; - } - - let mut numbers = Vec::new(); - for part in value.split(',') { - let part = part.trim(); - if part.is_empty() { - return None; - } - let number = part.parse::().ok()?; - numbers.push(number); - } - (!numbers.is_empty()).then_some(numbers) -} - -fn tidy_citation_spacing(value: &str) -> String { - let mut tidied = value.trim().to_string(); - for (from, to) in [ - (" .", "."), - (" ,", ","), - (" ;", ";"), - (" :", ":"), - (" !", "!"), - (" ?", "?"), - (" )", ")"), - ("( ", "("), - ] { - tidied = tidied.replace(from, to); - } - while tidied.contains(" ") { - tidied = tidied.replace(" ", " "); - } - tidied -} + AETHER_OPEN_BROWSER_MENU_ID => { + let _ = app.emit(AETHER_SHORTCUT_EVENT, "open-browser"); + } + AETHER_TOGGLE_AION_MENU_ID => { + let _ = app.emit(AETHER_SHORTCUT_EVENT, "toggle-aion"); + } + AETHER_CAPTURE_PAGE_MENU_ID => { + let _ = app.emit(AETHER_SHORTCUT_EVENT, "capture-page"); + } + _ => {} + }); -async fn extract_readable_active_page( - state: &State<'_, Backend>, - active_tab: &ManagedTab, -) -> Cmd { + let builder = builder.plugin(tauri_plugin_opener::init()); + // Registered even when no pubkey is configured: the plugin only fails at check + // time, and aether_system_install_update turns that into one honest message + // instead of a failed download. See updater_pubkey(). #[cfg(desktop)] - { - match extract_readable_page_from_webview(state, active_tab).await { - Ok(page) => return Ok(page), - Err(_) => {} - } - } - + let builder = builder.plugin(tauri_plugin_updater::Builder::new().build()); #[cfg(target_os = "android")] - { - match extract_readable_page_from_android(active_tab) { - Ok(page) => return Ok(page), - Err(_) => {} - } - } - - extract_readable_page(&state.client, &active_tab.url).await -} + let builder = builder.plugin(android_tabs::init()); -// Android counterpart of extract_readable_page_from_webview: the Kotlin -// TabsPlugin's `snapshot` command reads the live DOM through -// evaluateJavascript's value callback, so logged-in and JS-rendered pages -// capture correctly instead of falling back to an anonymous HTTP re-fetch. -#[cfg(target_os = "android")] -fn extract_readable_page_from_android(active_tab: &ManagedTab) -> Cmd { - let response: android_tabs::SnapshotResponse = android_tabs::run_for_global( - "snapshot", - android_tabs::TabPayload { - tab_id: &active_tab.id, - }, - )?; - let payload = response.payload.trim(); - if payload.is_empty() || payload == "null" { - return Err("Unable to read the active page.".to_string()); - } - let snapshot = parse_page_snapshot(payload)?; - snapshot_to_captured_page(snapshot, &active_tab.title) -} + builder + .setup(|app| { + let app_data_dir = app.path().app_data_dir().expect("app data dir"); + // Before anything else: session restore and model prewarm both log, + // and those are the entries most worth having in a bug report. + diagnostics::set_log_path(&app_data_dir); + app.manage(Backend::new(app_data_dir)); -#[cfg(desktop)] -async fn extract_readable_page_from_webview( - state: &State<'_, Backend>, - active_tab: &ManagedTab, -) -> Cmd { - let webview = state - .webviews - .lock() - .map_err(|_| "Æther webviews are unavailable.".to_string())? - .views - .get(&active_tab.id) - .cloned() - .ok_or_else(|| "Active browser webview is not ready.".to_string())?; - let script = r#"(() => { - const clone = document.documentElement.cloneNode(true); - clone.querySelectorAll('script, style, noscript, iframe, form, nav, footer, svg').forEach((node) => node.remove()); - return { - html: '' + clone.outerHTML, - url: location.href, - title: document.title, - description: document.querySelector('meta[name="description"]')?.getAttribute('content') || '', - bodyText: document.body?.innerText || '' - }; - })()"#; - let (sender, receiver) = tokio::sync::oneshot::channel::(); - let sender = Arc::new(Mutex::new(Some(sender))); - webview - .eval_with_callback(script, { - let sender = Arc::clone(&sender); - move |payload| { - if let Ok(mut sender) = sender.lock() { - if let Some(sender) = sender.take() { - let _ = sender.send(payload); + // Restore the previous session before anything reads tab state or + // prewarms a webview, so the restored active tab is the one warmed. + #[cfg(desktop)] + let restored_window = { + let app_handle = app.handle().clone(); + let state = app_handle.state::(); + match tauri::async_runtime::block_on(load_session(&state.paths.session_path)) { + Ok(session) => { + restore_session_tabs(&state, &session); + session.window + } + Err(error) => { + diag_warn!("could not read session: {error}"); + None } } - } - }) - .map_err(|error| error.to_string())?; - let payload = tokio::time::timeout(Duration::from_secs(5), receiver) - .await - .map_err(|_| "Timed out reading the active page.".to_string())? - .map_err(|_| "Unable to read the active page.".to_string())?; - let snapshot = parse_page_snapshot(&payload)?; - snapshot_to_captured_page(snapshot, &active_tab.title) -} - -fn parse_page_snapshot(payload: &str) -> Cmd { - parse_json_payload::(payload) -} - -fn parse_json_payload(payload: &str) -> Cmd { - let value = - serde_json::from_str::(payload).map_err(|error| error.to_string())?; - if let Some(inner) = value.as_str() { - serde_json::from_str::(inner).map_err(|error| error.to_string()) - } else { - serde_json::from_value::(value).map_err(|error| error.to_string()) - } -} - -fn snapshot_to_captured_page( - snapshot: BrowserPageSnapshot, - fallback_title: &str, -) -> Cmd { - let url = snapshot - .url - .filter(|url| !url.trim().is_empty()) - .ok_or_else(|| "Unable to read the active page.".to_string())?; - let parsed_document = snapshot - .html - .as_ref() - .map(|html| Html::parse_document(html)); - let title = snapshot - .title - .filter(|title| !title.trim().is_empty()) - .or_else(|| { - parsed_document - .as_ref() - .and_then(|document| select_first_text(document, "title")) - }) - .unwrap_or_else(|| fallback_title.to_string()); - let description = snapshot.description.unwrap_or_else(|| { - parsed_document - .as_ref() - .and_then(|document| select_meta_content(document, "description")) - .unwrap_or_default() - }); - let body_text = snapshot.body_text.unwrap_or_else(|| { - parsed_document - .as_ref() - .map(select_body_text) - .unwrap_or_default() - }); - let text = normalize_captured_text(&format!("{title}\n\n{description}\n\n{body_text}")); - - if text.len() < MIN_CAPTURE_TEXT_LENGTH { - return Err("This page does not contain enough readable text to capture.".to_string()); - } - - Ok(CapturedPage { title, url, text }) -} - -async fn extract_readable_page(client: &Client, url: &str) -> Cmd { - let parsed = Url::parse(url).map_err(|_| "Unable to read the active page URL.".to_string())?; - if parsed.scheme() != "http" && parsed.scheme() != "https" { - return Err("Only http and https pages can be captured in the Tauri build.".to_string()); - } - let response = client - .get(url) - .timeout(Duration::from_secs(20)) - .send() - .await - .map_err(|error| error.to_string())?; - if !response.status().is_success() { - return Err(format!("Unable to fetch page: {}", response.status())); - } - let html = response.text().await.map_err(|error| error.to_string())?; - let document = Html::parse_document(&html); - let title = select_first_text(&document, "title") - .filter(|title| !title.is_empty()) - .unwrap_or_else(|| title_from_url(url)); - let description = select_meta_content(&document, "description").unwrap_or_default(); - let body_text = select_body_text(&document); - let text = normalize_captured_text(&format!("{title}\n\n{description}\n\n{body_text}")); - if text.len() < MIN_CAPTURE_TEXT_LENGTH { - return Err("This page does not contain enough readable text to capture.".to_string()); - } - Ok(CapturedPage { - title, - url: url.to_string(), - text, - }) -} - -fn select_first_text(document: &Html, selector: &str) -> Option { - let selector = Selector::parse(selector).ok()?; - document - .select(&selector) - .next() - .map(|node| node.text().collect::>().join(" ").trim().to_string()) -} - -fn select_meta_content(document: &Html, name: &str) -> Option { - let selector = Selector::parse(&format!("meta[name=\"{name}\"]")).ok()?; - document - .select(&selector) - .next() - .and_then(|node| node.value().attr("content")) - .map(|value| value.trim().to_string()) -} - -fn select_body_text(document: &Html) -> String { - let selector = Selector::parse("body").expect("body selector"); - document - .select(&selector) - .flat_map(|node| node.text()) - .map(str::trim) - .filter(|text| !text.is_empty()) - .collect::>() - .join(" ") -} - -fn build_iceberg_prompt(keyword: &str) -> String { - format!( - r#"Create an iceberg chart for the topic "{keyword}". - -Return JSON only with this exact shape: -{{ - "recommendedItemCount": 24, - "items": [ - {{ - "name": "Visible phrase", - "description": "One short explanation.", - "depthScore": 12, - "familiarity": 85, - "specificity": 20, - "jargonDensity": 15, - "prerequisiteDepth": 10, - "obscurity": 12, - "confidence": 0.86, - "reason": "Common public entry point." - }} - ] -}} - -Rules: -- Choose recommendedItemCount based on topic scope: 12-18 for narrow topics, 20-30 for medium topics, 32-45 for broad domains. -- Return between 12 and 45 usable items. -- Cover all five iceberg layers whenever the topic has enough material. Include at least one item intentionally scored for each depth band: 0-19, 20-39, 40-59, 60-79, and 80-100. -- Prefer 2-5 genuinely obscure or specialist items for the 80-100 band instead of stopping at intermediate concepts. -- Do not include more than 10 items that would clearly belong to the same depth band. -- Use concise item names that fit on a node. -- Every item must include a non-empty description. -- Scores are integers from 0-100, never 1-10. Confidence is 0.0-1.0. -- depthScore: intended iceberg depth, where 0-19 is public surface knowledge and 80-100 is obscure, specialist, prerequisite-heavy, or rarely explained. -- familiarity: how likely a general audience already knows this. -- specificity: how narrow the concept is within the topic. -- jargonDensity: how much specialist vocabulary is needed. -- prerequisiteDepth: how much prior conceptual knowledge is required. -- obscurity: how rarely this appears in mainstream explainers or beginner material. -- reason: one short explanation for why the item sits at its depth. -- Do not include level; the app will compute levels from the scoring rubric. -- Do not include markdown, prose, or comments."# - ) -} - -#[derive(Clone)] -struct ScoredIcebergItem { - item: IcebergItem, - score: f64, -} - -fn normalize_iceberg_items(response: &str) -> Cmd> { - let json_text = response - .trim() - .trim_start_matches("```json") - .trim_start_matches("```") - .trim_end_matches("```") - .trim(); - let parsed = match serde_json::from_str::(json_text) { - Ok(value) => value, - Err(_) => { - let start = json_text - .find('[') - .ok_or_else(|| "Local model did not return valid iceberg JSON.".to_string())?; - let end = json_text - .rfind(']') - .ok_or_else(|| "Local model did not return valid iceberg JSON.".to_string())?; - serde_json::from_str(&json_text[start..=end]) - .map_err(|_| "Local model did not return valid iceberg JSON.".to_string())? - } - }; - let requested_count = iceberg_requested_count(&parsed); - let items_value = parsed.get("items").cloned().unwrap_or(parsed); - let raw_items = items_value - .as_array() - .ok_or_else(|| "Local model did not return valid iceberg JSON.".to_string())?; - let target_count = requested_count - .unwrap_or(raw_items.len()) - .clamp(ICEBERG_MIN_ITEMS, ICEBERG_MAX_ITEMS); - let mut candidates = Vec::::new(); - let mut seen_names = HashSet::::new(); - for raw in raw_items { - let name = raw - .get("name") - .and_then(|value| value.as_str()) - .unwrap_or("") - .trim(); - let description = raw - .get("description") - .and_then(|value| value.as_str()) - .unwrap_or("") - .trim(); - if name.is_empty() || description.is_empty() { - continue; - } - - let dedupe_key = slugify(name); - if dedupe_key.is_empty() || !seen_names.insert(dedupe_key) { - continue; - } - - let fallback_level = iceberg_level_field(raw).unwrap_or(3); - let fallback_score = iceberg_fallback_score(fallback_level); - let familiarity = iceberg_metric_field(raw, &["familiarity"]); - let specificity = iceberg_metric_field(raw, &["specificity"]); - let jargon_density = iceberg_metric_field(raw, &["jargonDensity", "jargon_density"]); - let prerequisite_depth = - iceberg_metric_field(raw, &["prerequisiteDepth", "prerequisite_depth"]); - let obscurity = iceberg_metric_field(raw, &["obscurity"]); - let confidence = iceberg_confidence_field(raw); - let explicit_depth = - iceberg_metric_field(raw, &["depthScore", "depth_score", "complexityScore"]); - let score = explicit_depth.unwrap_or_else(|| { - iceberg_depth_score( - familiarity, - specificity, - jargon_density, - prerequisite_depth, - obscurity, - fallback_score, - ) - }); - let score = round_score(score.clamp(0.0, 100.0)); - let level = iceberg_level_from_score(score); - let reason = iceberg_string_field(raw, &["reason", "rationale", "levelReason"]) - .map(|reason| reason.chars().take(220).collect::()); - - candidates.push(ScoredIcebergItem { - item: IcebergItem { - id: String::new(), - name: name.to_string(), - description: description.to_string(), - level, - x: 0.0, - y: 0.0, - depth_score: Some(score), - familiarity, - specificity, - jargon_density, - prerequisite_depth, - obscurity, - confidence, - reason, - }, - score, - }); - } - - stretch_iceberg_candidate_levels(&mut candidates); - - candidates.sort_by(|left, right| { - left.item - .level - .cmp(&right.item.level) - .then_with(|| { - left.score - .partial_cmp(&right.score) - .unwrap_or(Ordering::Equal) - }) - .then_with(|| left.item.name.cmp(&right.item.name)) - }); - - let mut buckets = HashMap::>::new(); - for candidate in candidates { - buckets.entry(candidate.item.level).or_default().push(candidate); - } - - let mut selected = Vec::::new(); - let mut by_level = HashMap::::new(); + }; - for level in 1..=ICEBERG_LEVEL_COUNT { - if selected.len() >= target_count { - break; - } - if let Some(candidate) = take_iceberg_candidate(&mut buckets, level) { - *by_level.entry(level).or_default() += 1; - selected.push(candidate); - } - } + #[cfg(desktop)] + if let Some(window) = app.get_window("main") { + if let Some(geometry) = restored_window { + apply_session_window(&window, geometry); + } - while selected.len() < target_count { - let mut added_any = false; - for level in 1..=ICEBERG_LEVEL_COUNT { - if selected.len() >= target_count { - break; - } - if by_level.get(&level).copied().unwrap_or_default() >= ICEBERG_MAX_ITEMS_PER_LEVEL { - continue; + let app_handle = app.handle().clone(); + window.on_window_event(move |event| { + match event { + WindowEvent::Resized(_) | WindowEvent::ScaleFactorChanged { .. } => { + let state = app_handle.state::(); + let _ = resize_native_webviews(&app_handle, &state); + schedule_window_geometry_save(&app_handle); + } + WindowEvent::Moved(_) => schedule_window_geometry_save(&app_handle), + // force_exit() follows a close, so this is the last chance to + // capture the final geometry the throttle may have skipped. + WindowEvent::CloseRequested { .. } | WindowEvent::Destroyed => { + save_window_geometry_now(&app_handle) + } + _ => {} + } + }); } - if let Some(candidate) = take_iceberg_candidate(&mut buckets, level) { - *by_level.entry(level).or_default() += 1; - selected.push(candidate); - added_any = true; + #[cfg(desktop)] + { + let app_handle = app.handle().clone(); + let state = app_handle.state::(); + if let Ok(active_tab_id) = active_tab_id(&state) { + if let Err(error) = ensure_native_webview(&app_handle, &state, &active_tab_id) { + diag_warn!("browser webview prewarm failed: {error}"); + } + } + prewarm_local_models(&app_handle); } - } - - if !added_any { - break; - } - } - - selected.sort_by(|left, right| { - left.item - .level - .cmp(&right.item.level) - .then_with(|| { - left.score - .partial_cmp(&right.score) - .unwrap_or(Ordering::Equal) - }) - .then_with(|| left.item.name.cmp(&right.item.name)) - }); - - let mut normalized = Vec::::new(); - let mut ids = Vec::::new(); - let mut layout_counts = HashMap::::new(); - for candidate in selected { - let level_count = layout_counts.entry(candidate.item.level).or_default(); - let index = *level_count; - *level_count += 1; - let mut item = candidate.item; - item.id = unique_slug(&format!("{}-{}-{}", item.level, index + 1, item.name), &ids); - ids.push(item.id.clone()); - item.x = ICEBERG_LEVEL_LANES[index % ICEBERG_LEVEL_LANES.len()]; - item.y = 120.0 + index as f64 * 44.0; - normalized.push(item); - } - - if normalized.is_empty() { - Err("Local model did not return any usable iceberg items.".to_string()) - } else { - Ok(normalized) - } -} - -fn stretch_iceberg_candidate_levels(candidates: &mut [ScoredIcebergItem]) { - if candidates.len() < ICEBERG_LEVEL_COUNT as usize { - return; - } - - let present_levels = candidates - .iter() - .map(|candidate| candidate.item.level) - .collect::>(); - if present_levels.len() >= ICEBERG_LEVEL_COUNT as usize { - return; - } - - // Small local models often compress everything into middle scores. For iCE, - // the useful ranking is relative to the requested topic, so spread a usable - // set across all five bands instead of returning empty deep layers. - let mut indices = (0..candidates.len()).collect::>(); - indices.sort_by(|left, right| { - candidates[*left] - .score - .partial_cmp(&candidates[*right].score) - .unwrap_or(Ordering::Equal) - .then_with(|| candidates[*left].item.name.cmp(&candidates[*right].item.name)) - }); - let total = candidates.len(); - for (rank, index) in indices.into_iter().enumerate() { - let level = ((rank * ICEBERG_LEVEL_COUNT as usize) / total + 1) - .min(ICEBERG_LEVEL_COUNT as usize) as u8; - let score = iceberg_score_for_level_band(candidates[index].score, level); - candidates[index].score = score; - candidates[index].item.level = level; - candidates[index].item.depth_score = Some(score); - } -} - -fn iceberg_score_for_level_band(score: f64, level: u8) -> f64 { - let level = level.clamp(1, ICEBERG_LEVEL_COUNT); - let lower = f64::from(level.saturating_sub(1)) * 20.0; - let upper = if level == ICEBERG_LEVEL_COUNT { - 100.0 - } else { - f64::from(level) * 20.0 - 1.0 - }; - round_score(score.clamp(lower, upper)) -} - -fn take_iceberg_candidate( - buckets: &mut HashMap>, - level: u8, -) -> Option { - let bucket = buckets.get_mut(&level)?; - if bucket.is_empty() { - None - } else { - Some(bucket.remove(0)) - } -} - -fn iceberg_requested_count(value: &serde_json::Value) -> Option { - let count = value - .get("recommendedItemCount") - .or_else(|| value.get("itemCount")) - .or_else(|| value.get("recommended_count")) - .and_then(|value| value.as_u64())?; - usize::try_from(count).ok() -} - -fn iceberg_level_field(value: &serde_json::Value) -> Option { - let level = value - .get("level") - .or_else(|| value.get("proposedLevel")) - .or_else(|| value.get("depthLevel")) - .and_then(|value| value.as_u64())?; - Some((level as u8).clamp(1, ICEBERG_LEVEL_COUNT)) -} - -fn iceberg_metric_field(value: &serde_json::Value, names: &[&str]) -> Option { - names - .iter() - .find_map(|name| value.get(*name).and_then(json_number)) - .map(normalize_iceberg_metric) -} - -fn iceberg_confidence_field(value: &serde_json::Value) -> Option { - value - .get("confidence") - .and_then(json_number) - .map(normalize_confidence) -} - -fn iceberg_string_field(value: &serde_json::Value, names: &[&str]) -> Option { - names - .iter() - .find_map(|name| value.get(*name).and_then(|value| value.as_str())) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(ToString::to_string) -} - -fn json_number(value: &serde_json::Value) -> Option { - value - .as_f64() - .or_else(|| { - value - .as_str() - .and_then(|text| text.trim().parse::().ok()) + Ok(()) }) - .filter(|value| value.is_finite()) -} - -fn normalize_iceberg_metric(value: f64) -> f64 { - let normalized = if (0.0..=1.0).contains(&value) { - value * 100.0 - } else { - value - }; - round_score(normalized.clamp(0.0, 100.0)) -} - -fn normalize_confidence(value: f64) -> f64 { - let normalized = if value > 10.0 { - value / 100.0 - } else if value > 1.0 { - value / 10.0 - } else { - value - }; - round_score(normalized.clamp(0.0, 1.0)) -} - -fn iceberg_fallback_score(level: u8) -> f64 { - match level.clamp(1, ICEBERG_LEVEL_COUNT) { - 1 => 10.0, - 2 => 30.0, - 3 => 50.0, - 4 => 70.0, - _ => 90.0, - } -} - -fn iceberg_depth_score( - familiarity: Option, - specificity: Option, - jargon_density: Option, - prerequisite_depth: Option, - obscurity: Option, - fallback_score: f64, -) -> f64 { - let familiarity = familiarity.unwrap_or(100.0 - fallback_score); - let specificity = specificity.unwrap_or(fallback_score); - let jargon_density = jargon_density.unwrap_or(fallback_score); - let prerequisite_depth = prerequisite_depth.unwrap_or(fallback_score); - let obscurity = obscurity.unwrap_or(fallback_score); - - specificity * 0.3 - + jargon_density * 0.25 - + prerequisite_depth * 0.2 - + obscurity * 0.15 - + (100.0 - familiarity) * 0.1 -} - -fn iceberg_level_from_score(score: f64) -> u8 { - match score { - value if value < 20.0 => 1, - value if value < 40.0 => 2, - value if value < 60.0 => 3, - value if value < 80.0 => 4, - _ => 5, - } -} - -fn clamp_optional_score(value: Option, min: f64, max: f64) -> Option { - value - .filter(|value| value.is_finite()) - .map(|value| round_score(value.clamp(min, max))) -} - -fn normalize_saved_items(items: Vec) -> Vec { - items - .into_iter() - .filter(|item| !item.name.trim().is_empty() && !item.description.trim().is_empty()) - .map(|mut item| { - item.name = item.name.trim().to_string(); - item.description = item.description.trim().to_string(); - item.level = item.level.clamp(1, ICEBERG_LEVEL_COUNT); - item.depth_score = clamp_optional_score(item.depth_score, 0.0, 100.0); - item.familiarity = clamp_optional_score(item.familiarity, 0.0, 100.0); - item.specificity = clamp_optional_score(item.specificity, 0.0, 100.0); - item.jargon_density = clamp_optional_score(item.jargon_density, 0.0, 100.0); - item.prerequisite_depth = clamp_optional_score(item.prerequisite_depth, 0.0, 100.0); - item.obscurity = clamp_optional_score(item.obscurity, 0.0, 100.0); - item.confidence = clamp_optional_score(item.confidence, 0.0, 1.0); - item.reason = item - .reason - .map(|reason| reason.trim().chars().take(220).collect::()) - .filter(|reason| !reason.is_empty()); - if item.id.trim().is_empty() { - item.id = unique_slug(&format!("{}-{}", item.level, item.name), &[]); + .invoke_handler(tauri::generate_handler![ + aether_state, + aether_apps_list, + aether_apps_activate, + aether_apps_navigate, + aether_apps_go_back, + aether_apps_go_forward, + aether_tabs_list, + aether_tabs_create, + aether_tabs_activate, + aether_tabs_close, + aether_tabs_navigate, + aether_tabs_scroll_to_text, + aether_tabs_find, + aether_tabs_go_back, + aether_tabs_go_forward, + aether_tabs_report_native_event, + aether_tabs_thumbnail, + aether_layout_window_insets, + aether_layout_set_web_content_bounds, + aether_dashboard_open, + aether_hub_list, + aether_hub_create, + aether_hub_reorder, + aether_hub_delete, + aether_collections_list, + aether_collections_create, + aether_collections_update, + aether_collections_reorder, + aether_collections_delete, + aether_collections_captures, + aether_capture_current_page, + aether_capture_url, + aether_capture_urls, + aether_capture_move, + aether_capture_delete, + aether_capture_suggest_hub, + aether_search_collection, + aether_search_library, + aether_semantic_trail_generate, + aether_flow_graph, + aether_air_prepare, + aether_air_render, + aether_air_list_recent, + aether_air_open, + aether_air_reveal, + aether_chat_ask, + aether_chat_cancel, + aether_chat_history, + aether_chat_clear_history, + aether_crystallizer_generate, + aether_crystallizer_list_saved, + aether_crystallizer_get_saved, + aether_crystallizer_save, + aether_crystallizer_reorder_saved, + aether_crystallizer_delete_saved, + aether_system_status, + aether_system_settings, + aether_system_update_settings, + aether_system_update_models, + aether_system_check_for_update, + aether_system_install_update, + aether_system_relaunch, + aether_system_download_models, + aether_system_export_library, + aether_system_diagnostics, + aether_system_export_diagnostics, + aether_library_reindex, + aether_library_index_status, + aether_system_open_external_url, + aether_layout_set_panel_collapsed, + aether_layout_set_modal_overlay_open, + aether_layout_show_status_toast + ]) + .build(tauri::generate_context!()) + .expect("error while building Æther") + .run(|_app_handle, _event| { + #[cfg(desktop)] + if let tauri::RunEvent::ExitRequested { .. } = _event { + force_exit(); } - item - }) - .collect() + }); } -fn saved_iceberg_summary(iceberg: &SavedIceberg) -> SavedIcebergSummary { - SavedIcebergSummary { - id: iceberg.id.clone(), - title: iceberg.title.clone(), - keyword: iceberg.iceberg.keyword.clone(), - model: iceberg.iceberg.model.clone(), - icon: iceberg.icon.clone(), - generated_at: iceberg.iceberg.generated_at.clone(), - saved_at: iceberg.saved_at.clone(), - updated_at: iceberg.updated_at.clone(), - item_count: iceberg.iceberg.items.len(), - } -} +#[cfg(desktop)] +fn prewarm_local_models(app: &AppHandle) { + let state = app.state::(); + let paths = state.paths.clone(); + let runtime = Arc::clone(&state.native_runtime); -fn dedupe_citations(citations: Vec) -> Vec { - let mut unique = Vec::::new(); - let mut indexes = HashMap::::new(); - for citation in citations { - let key = normalize_citation_key(&citation.url); - if let Some(existing_index) = indexes.get(&key).copied() { - let existing = &mut unique[existing_index]; - if !existing.text.contains(&citation.text) { - // Join non-contiguous excerpts from the same page with a neutral - // marker. A numbered "Chunk N" label here leaks into the model's - // context and gets echoed as an uncitable "[Chunk N]" reference. - existing.text = format!("{}\n\n[…]\n\n{}", existing.text, citation.text) - .chars() - .take(9000) - .collect(); + tauri::async_runtime::spawn(async move { + let Ok(settings) = load_settings(&paths.settings_path).await else { + return; + }; + let catalog = model_catalog(&paths, &settings.local_model); + let chat_model = catalog.chat_model; + let embedding_model = catalog.embedding_model; + if chat_model.is_none() && embedding_model.is_none() { + return; + } + let result = task::spawn_blocking(move || { + let mut runtime = runtime + .lock() + .map_err(|_| "Local model runtime is unavailable.".to_string())?; + if let Some(model_path) = &chat_model { + runtime + .ensure_model(NativeModelKind::Chat, model_path) + .map_err(|error| { + format!("chat model {} failed: {error}", model_label(model_path)) + })?; } - existing.score = existing.score.min(citation.score); - } else { - indexes.insert(key, unique.len()); - unique.push(citation); + if let Some(model_path) = &embedding_model { + runtime.warm_embedding_model(model_path).map_err(|error| { + format!( + "embedding model {} failed: {error}", + model_label(model_path) + ) + })?; + } + Ok::<(), String>(()) + }) + .await; + + match result { + Ok(Ok(())) => {} + Ok(Err(error)) => diag_warn!("model prewarm failed: {error}"), + Err(error) => diag_warn!("model prewarm task failed: {error}"), } - } - unique + }); } -fn split_text(text: &str, chunk_size: usize, overlap: usize) -> Vec { - let chars = text.chars().collect::>(); - let mut chunks = Vec::new(); - let mut start = 0; - while start < chars.len() { - let end = (start + chunk_size).min(chars.len()); - let chunk = chars[start..end] - .iter() - .collect::() - .trim() - .to_string(); - if !chunk.is_empty() { - chunks.push(chunk); - } - if end == chars.len() { - break; - } - start = end.saturating_sub(overlap); - } - chunks +fn emit_capture_progress( + app: &AppHandle, + message: impl Into, + current: Option, + total: Option, +) { + let _ = app.emit( + "aether:capture-progress", + CaptureProgress { + message: message.into(), + current, + total, + }, + ); } -fn cosine_distance(left: &[f32], right: &[f32]) -> f64 { - if left.is_empty() || left.len() != right.len() { - return f64::INFINITY; - } - let mut dot = 0.0_f64; - let mut left_norm = 0.0_f64; - let mut right_norm = 0.0_f64; - for (left, right) in left.iter().zip(right.iter()) { - let left = *left as f64; - let right = *right as f64; - dot += left * right; - left_norm += left * left; - right_norm += right * right; - } - if left_norm == 0.0 || right_norm == 0.0 { - f64::INFINITY - } else { - 1.0 - dot / (left_norm.sqrt() * right_norm.sqrt()) - } +async fn navigate_active_tab(app: &AppHandle, state: &State<'_, Backend>, url: &str) -> Cmd<()> { + let settings = load_settings(&state.paths.settings_path).await?; + let (tab_id, target_url) = { + let mut tabs = lock_tabs(state)?; + let tab = tabs + .active_tab_mut() + .ok_or_else(|| "No active browser tab.".to_string())?; + tab.navigate(url, &settings.browser.default_search_engine); + let result = (tab.id.clone(), tab.url.clone()); + tabs.dashboard_open = false; + result + }; + navigate_native_webview(app, state, &tab_id, &target_url)?; + emit_state(app, state) } fn lock_tabs<'a>(state: &'a State<'_, Backend>) -> Cmd> { state .tabs .lock() - .map_err(|_| "Æther tab state is unavailable.".to_string()) + .map_err(|_| "tab state is unavailable.".to_string()) } fn emit_state(app: &AppHandle, state: &State) -> Cmd<()> { @@ -10200,246 +1010,302 @@ fn active_tab_id(state: &State) -> Cmd { Ok(lock_tabs(state)?.active_tab_id.clone()) } -fn reorder(items: Vec, ids: &[String], id_of: F) -> Vec -where - T: Clone, - F: Fn(&T) -> &String, -{ - let requested = ids.iter().filter(|id| !id.is_empty()).collect::>(); - let requested_set = requested - .iter() - .map(|id| (*id).clone()) - .collect::>(); - let by_id = items - .iter() - .map(|item| (id_of(item).clone(), item.clone())) - .collect::>(); - let mut ordered = requested - .into_iter() - .filter_map(|id| by_id.get(id).cloned()) - .collect::>(); - ordered.extend( - items - .into_iter() - .filter(|item| !requested_set.contains(id_of(item))), - ); - ordered -} +#[cfg(test)] +mod tests { + use super::*; -fn normalize_captured_text(text: &str) -> String { - text.replace('\r', "") - .split('\n') - .map(str::trim) - .collect::>() - .join("\n") - .split_whitespace() - .collect::>() - .join(" ") - .trim() - .to_string() -} + // The log's whole purpose is to exist when something has gone wrong on a + // platform nobody could test, so the write path cannot be left to be exercised + // for the first time by the failure it is meant to record. Covers the append, + // the directory being created on demand, and the rollover — which is the part + // that could silently throw away the file. + #[test] + fn diagnostics_log_appends_and_rolls_over() { + use crate::diagnostics::{DiagnosticEntry, DiagnosticLevel}; -fn normalize_url(raw_url: &str, search_engine: &str) -> String { - let trimmed = raw_url.trim(); - if trimmed.is_empty() { - return "https://www.google.com".to_string(); - } - if Url::parse(trimmed).is_ok() { - return trimmed.to_string(); - } - if trimmed.contains(char::is_whitespace) || !trimmed.contains(['.', ':']) { - return format!( - "{}{}", - search_engine_prefix(search_engine), - urlencoding(trimmed) - ); - } - if trimmed.starts_with("localhost") - || trimmed.starts_with("127.0.0.1") - || trimmed.starts_with("[::1]") - { - return format!("http://{trimmed}"); - } - format!("https://{trimmed}") -} + let dir = TempDir::new(); + // Deliberately a path whose parent does not exist yet: the first entry is + // usually written before anything has created the directory. + let path = dir.path("aether-diagnostics").join("aether.log"); + + let entry = |message: &str| DiagnosticEntry { + at: "2026-07-25T12:00:00.000Z".to_string(), + level: DiagnosticLevel::Warn, + message: message.to_string(), + }; -fn search_engine_prefix(id: &str) -> &'static str { - match id { - "bing" => "https://www.bing.com/search?q=", - "yahoo" => "https://search.yahoo.com/search?p=", - "ecosia" => "https://www.ecosia.org/search?q=", - "duckduckgo" => "https://duckduckgo.com/?q=", - _ => "https://www.google.com/search?q=", - } -} + crate::diagnostics::write_entry(&path, &entry("first failure")); + crate::diagnostics::write_entry(&path, &entry("second failure")); -fn normalize_search_engine_id(value: &str) -> String { - match value { - "google" | "bing" | "yahoo" | "ecosia" | "duckduckgo" => value.to_string(), - _ => "google".to_string(), - } -} + let written = fs::read_to_string(&path).expect("log should exist"); + assert!(written.contains("first failure")); + assert!(written.contains("second failure")); + assert!( + written.contains("[warn]"), + "the level has to survive into the file, or an exported log cannot be \ + triaged: {written}" + ); + assert_eq!( + written.lines().count(), + 2, + "entries must append, not replace" + ); -fn normalize_iceberg_icon(value: Option) -> Option { - let allowed = [ - "atom", - "book", - "brain", - "briefcase", - "code", - "cpu", - "dna", - "film", - "flask", - "gamepad", - "globe", - "heart", - "landmark", - "microscope", - "music", - "palette", - "shield", - "snowflake", - "sprout", - "telescope", - ]; - value - .map(|icon| icon.trim().to_lowercase()) - .filter(|icon| allowed.contains(&icon.as_str())) -} + // Past the cap, the oldest half goes and the newest half stays. Discarding + // everything would leave an empty log exactly when one is wanted. + // ~44 bytes a line, so this has to clear MAX_LOG_BYTES (512 KiB). + let bulk = (0..16_000) + .map(|index| format!("2026-07-25T12:00:00.000Z [warn] filler {index}")) + .collect::>() + .join("\n"); + fs::write(&path, format!("{bulk}\n")).expect("seed a large log"); + assert!(fs::metadata(&path).expect("metadata").len() > 512 * 1024); -fn normalize_theme_color(color: &str) -> Option { - let value = color.trim().chars().take(64).collect::(); - if value.is_empty() { - return None; - } + crate::diagnostics::write_entry(&path, &entry("after rollover")); - if let Some(hex) = value.strip_prefix('#') { - if (3..=8).contains(&hex.len()) - && hex.chars().all(|character| character.is_ascii_hexdigit()) - { - return Some(value); - } + let rolled = fs::read_to_string(&path).expect("log should survive rollover"); + assert!( + rolled.contains("after rollover"), + "the entry that triggered the rollover must still be recorded" + ); + assert!( + rolled.contains("filler 15999"), + "the newest entries must be the ones kept" + ); + assert!( + !rolled.contains("filler 0\n"), + "the oldest entries should have been dropped" + ); + assert!(rolled.len() < bulk.len(), "the file should have shrunk"); } - let lower = value.to_ascii_lowercase(); - let supported_function = lower.starts_with("rgb(") - || lower.starts_with("rgba(") - || lower.starts_with("hsl(") - || lower.starts_with("hsla("); - if supported_function && value.ends_with(')') { - return Some(value); - } + // Every colour in the renderer resolves through a channel token, and a typo in + // one is silent: `rgb(var(--surfce-rgb) / 0.7)` is simply an invalid + // declaration, so the element renders with no background at all rather than + // erroring. This catches that, and catches a channel added to the light theme + // without a dark counterpart — which is how a panel ends up white-on-navy. + #[test] + fn theme_channels_are_defined_and_themed() { + const STYLES: &str = "../src/renderer/src/assets/styles"; + let foundation = + std::fs::read_to_string(format!("{STYLES}/foundation.css")).expect("foundation.css"); + + // Definitions live in :root; the dark overrides come after it. + let root_end = foundation.find("\n}").expect("closing brace for :root"); + let (root, dark) = foundation.split_at(root_end); + + let defined: std::collections::HashSet<&str> = root + .lines() + .filter_map(|line| { + let line = line.trim(); + line.strip_prefix("--")? + .split_once(':') + .map(|(name, _)| name) + .filter(|name| name.ends_with("-rgb")) + }) + .collect(); + assert!( + defined.len() > 60, + "expected the full channel set, found {}", + defined.len() + ); - None -} + let mut referenced = std::collections::BTreeSet::new(); + for entry in std::fs::read_dir(STYLES).expect("styles dir") { + let path = entry.expect("dir entry").path(); + if path.extension().is_none_or(|ext| ext != "css") { + continue; + } + let css = std::fs::read_to_string(&path).expect("stylesheet"); + for (index, _) in css.match_indices("var(--") { + // Stop at the first character that cannot be part of an ident. A + // plain find(')') would run straight through `var(--x, rgb(...))` + // fallbacks and report the whole fallback as the token name. + let name: String = css[index + "var(--".len()..] + .chars() + .take_while(|c| c.is_ascii_alphanumeric() || *c == '-') + .collect(); + if name.ends_with("-rgb") { + referenced.insert(name); + } + } + } + + let undefined: Vec<_> = referenced + .iter() + .filter(|name| !defined.contains(name.as_str())) + .collect(); + assert!( + undefined.is_empty(), + "channels referenced but never defined (these render as invalid CSS, \ + silently dropping the declaration): {undefined:?}" + ); -fn title_from_url(url: &str) -> String { - let host = get_tab_host(url); - if host.is_empty() { - "New tab".to_string() - } else { - host + // Surfaces, ink, and the text-only aliases decide light versus dark. A new + // one without a dark value is the single most likely way to ship a + // white panel on a navy page. + let must_flip: Vec<_> = defined + .iter() + .filter(|name| { + name.starts_with("surface") + || name.starts_with("ink") + || name.starts_with("text-") + || name.starts_with("wordmark") + || [ + "highlight-rgb", + "edge-rgb", + "night-rgb", + "muted-rgb", + "faint-rgb", + ] + .contains(name) + }) + .filter(|name| !dark.contains(&format!("--{name}:"))) + .collect(); + assert!( + must_flip.is_empty(), + "channels with no dark-theme value: {must_flip:?}" + ); } -} -fn favicon_for_url(url: &str) -> Option { - let parsed = Url::parse(url).ok()?; - Some(format!( - "{}://{}/favicon.ico", - parsed.scheme(), - parsed.host_str()? - )) -} + // The shipped config carries an empty pubkey placeholder, and that has to read + // as "not configured" rather than as a key — otherwise the app offers an + // Install button that downloads a hundred megabytes and then fails signature + // verification. A pasted key with stray whitespace has to work. + #[cfg(desktop)] + #[test] + fn updater_pubkey_treats_a_placeholder_as_unconfigured() { + let placeholder = serde_json::json!({ "pubkey": "" }); + assert_eq!(updater_pubkey_from_config(Some(&placeholder)), None); -fn get_tab_host(url: &str) -> String { - Url::parse(url) - .ok() - .and_then(|url| { - url.host_str() - .map(|host| host.trim_start_matches("www.").to_string()) - }) - .unwrap_or_default() -} + let whitespace = serde_json::json!({ "pubkey": " \n" }); + assert_eq!(updater_pubkey_from_config(Some(&whitespace)), None); -fn normalize_citation_key(url: &str) -> String { - match Url::parse(url) { - Ok(mut parsed) => { - parsed.set_fragment(None); - parsed.to_string() - } - Err(_) => url.to_string(), - } -} + assert_eq!(updater_pubkey_from_config(None), None); + assert_eq!( + updater_pubkey_from_config(Some(&serde_json::json!({}))), + None + ); -fn normalize_capture_url_key(url: &str) -> String { - match Url::parse(url) { - Ok(mut parsed) => { - parsed.set_fragment(None); - if parsed.path() == "/" { - parsed.set_path(""); - } - parsed.to_string().trim_end_matches('/').to_string() - } - Err(_) => url.trim().trim_end_matches('/').to_string(), - } -} + let real = serde_json::json!({ "pubkey": " dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWdu\n" }); + assert_eq!( + updater_pubkey_from_config(Some(&real)).as_deref(), + Some("dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWdu") + ); -fn unique_slug(name: &str, existing: &[String]) -> String { - let base = slugify(name); - let mut candidate = base.clone(); - let mut suffix = 2; - while existing.contains(&candidate) { - candidate = format!("{base}-{suffix}"); - suffix += 1; + // And the real config file must still be a placeholder: committing a key + // here would be committing a trust anchor nobody reviewed. + let config: serde_json::Value = serde_json::from_str(include_str!("../tauri.conf.json")) + .expect("tauri.conf.json should be valid JSON"); + let shipped = updater_pubkey_from_config( + config + .get("plugins") + .and_then(|plugins| plugins.get("updater")), + ); + assert!( + shipped.is_none() || shipped.as_deref().is_some_and(|key| key.len() > 40), + "plugins.updater.pubkey should be either empty or a real minisign key" + ); } - candidate -} -fn slugify(value: &str) -> String { - let mut slug = String::new(); - let mut last_dash = false; - for char in value.trim().to_lowercase().chars() { - if char.is_ascii_alphanumeric() || char == '_' { - slug.push(char); - last_dash = false; - } else if !last_dash { - slug.push('-'); - last_dash = true; - } + // tauri-plugin-updater deserializes `plugins.updater` during plugin setup, and + // its `pubkey` field has no serde default — a missing or misshapen block fails + // app startup outright, not the update path. Since the key is pasted in by hand + // when signing is set up (docs/SIGNING.md), this checks the real config file + // rather than a fixture. + #[cfg(desktop)] + #[test] + fn updater_plugin_config_deserializes() { + let config: serde_json::Value = serde_json::from_str(include_str!("../tauri.conf.json")) + .expect("tauri.conf.json should be valid JSON"); + let updater = config + .get("plugins") + .and_then(|plugins| plugins.get("updater")) + .expect("plugins.updater should be configured"); + + let parsed: tauri_plugin_updater::Config = serde_json::from_value(updater.clone()) + .expect("plugins.updater must deserialize or the app fails to start"); + + // Empty endpoints make Updater::build() fail with EmptyEndpoints, which + // would surface to the user as a broken Install button. + assert!( + !parsed.endpoints.is_empty(), + "at least one update endpoint is required" + ); + assert!( + parsed.endpoints.iter().all(|url| url.scheme() == "https"), + "release builds reject non-https update endpoints" + ); } - let slug = slug.trim_matches('-').to_string(); - if slug.is_empty() { - "collection".to_string() - } else { - slug + + // The release workflow builds latest.json in bash (scripts/updater-manifest.sh) + // and the app reads it through tauri-plugin-updater, so nothing else checks + // that the two agree. A renamed field or a wrong nesting would be invisible + // until a real user pressed Install on a real release — the one place a + // mistake cannot be taken back. This deserializes the script's exact output + // with the plugin's own type. + // + // Regenerate with: + // scripts/updater-manifest.sh v1.0.30 CanPixel/aether /dev/stdout + #[cfg(desktop)] + #[test] + fn updater_manifest_matches_the_plugin_format() { + let manifest = r#"{ + "version": "1.0.30", + "notes": "See https://github.com/CanPixel/aether/releases/tag/v1.0.30", + "pub_date": "2026-07-25T12:32:59Z", + "platforms": { + "darwin-aarch64": { + "signature": "MACSIG==", + "url": "https://github.com/CanPixel/aether/releases/download/v1.0.30/AETHER_macOS.app.tar.gz" + }, + "darwin-x86_64": { + "signature": "MACSIG==", + "url": "https://github.com/CanPixel/aether/releases/download/v1.0.30/AETHER_macOS.app.tar.gz" + }, + "windows-x86_64": { + "signature": "WINSIG==", + "url": "https://github.com/CanPixel/aether/releases/download/v1.0.30/AETHER_x64-setup.exe" + }, + "linux-x86_64": { + "signature": "LXSIG==", + "url": "https://github.com/CanPixel/aether/releases/download/v1.0.30/AETHER_amd64.AppImage" } -} + } +}"#; -fn urlencoding(value: &str) -> String { - value - .bytes() - .flat_map(|byte| match byte { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - vec![byte as char] - } - b' ' => vec!['+'], - _ => format!("%{byte:02X}").chars().collect(), - }) - .collect() -} + let release: tauri_plugin_updater::RemoteRelease = + serde_json::from_str(manifest).expect("plugin should accept the generated manifest"); -fn uuid() -> String { - uuid::Uuid::new_v4().to_string() -} + assert_eq!(release.version.to_string(), "1.0.30"); -fn now() -> String { - Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true) -} + // These keys are `{os}-{arch}` as the plugin derives them at runtime. A + // typo here means the updater reports "no build for this platform" on a + // release that does in fact contain one. + for target in [ + "darwin-aarch64", + "darwin-x86_64", + "windows-x86_64", + "linux-x86_64", + ] { + let url = release + .download_url(target) + .unwrap_or_else(|error| panic!("no download url for {target}: {error}")); + assert!( + url.as_str() + .starts_with("https://github.com/CanPixel/aether/releases/download/v1.0.30/"), + "{target} url should be pinned to the tag, not to /latest/: {url}" + ); + assert!( + release.signature(target).is_ok(), + "no signature for {target}" + ); + } -#[cfg(test)] -mod tests { - use super::*; + // Linux ARM ships as a .deb only, so it is deliberately absent. The app + // turns this into the `unavailable` status rather than an error. + assert!(release.download_url("linux-aarch64").is_err()); + } fn block_on(future: F) -> F::Output { tokio::runtime::Builder::new_current_thread() @@ -10532,7 +1398,11 @@ mod tests { let recovered = block_on(read_json_or_default::(&path)).expect("recover"); assert_eq!(marker_of(&recovered), Some("good")); - assert_eq!(corrupt_count(&dir.0), 1, "damaged primary must be preserved"); + assert_eq!( + corrupt_count(&dir.0), + 1, + "damaged primary must be preserved" + ); // The restored primary must itself be readable on the next launch. let reread = block_on(read_json_or_default::(&path)).expect("reread"); assert_eq!(marker_of(&reread), Some("good")); @@ -10608,7 +1478,9 @@ mod tests { messages.iter().map(|m| m.role).collect::>(), vec!["system", "user"] ); - assert!(messages[1].content.contains("No stored context was retrieved.")); + assert!(messages[1] + .content + .contains("No stored context was retrieved.")); } // Replaying old citation markers would let the model reuse source numbers that no @@ -10661,9 +1533,16 @@ mod tests { } let thread = block_on(conversation_thread(&paths, Some("hub-1"))); - assert_eq!(thread.len(), MAX_THREAD_TURNS, "old turns should be trimmed"); + assert_eq!( + thread.len(), + MAX_THREAD_TURNS, + "old turns should be trimmed" + ); // The newest turns are the ones kept. - assert_eq!(thread.last().unwrap().prompt, format!("q{}", MAX_THREAD_TURNS + 4)); + assert_eq!( + thread.last().unwrap().prompt, + format!("q{}", MAX_THREAD_TURNS + 4) + ); assert_eq!(thread.first().unwrap().prompt, "q5"); // Threads must not leak into each other. @@ -10675,8 +1554,16 @@ mod tests { #[test] fn extractive_answer_quotes_sources_and_reports_no_generated_tokens() { let citations = vec![ - search_result("1", "https://example.com/a", "Quantum mechanics arose gradually."), - search_result("2", "https://example.com/b", "The Schrodinger equation governs."), + search_result( + "1", + "https://example.com/a", + "Quantum mechanics arose gradually.", + ), + search_result( + "2", + "https://example.com/b", + "The Schrodinger equation governs.", + ), ]; let result = extractive_answer(citations, 0.25); @@ -10747,11 +1634,15 @@ mod tests { let mut store = VectorStoreData::default(); store.push_chunks(vec![vector_chunk("a", vec![1.0, 2.0, 3.0, 4.0])]); block_on(save_vectors(&path, &mut store)).expect("first save"); - let size_after_first = fs::metadata(vector_data_path(&path)).expect("sidecar").len(); + let size_after_first = fs::metadata(vector_data_path(&path)) + .expect("sidecar") + .len(); store.push_chunks(vec![vector_chunk("b", vec![5.0, 6.0, 7.0, 8.0])]); block_on(save_vectors(&path, &mut store)).expect("second save"); - let size_after_second = fs::metadata(vector_data_path(&path)).expect("sidecar").len(); + let size_after_second = fs::metadata(vector_data_path(&path)) + .expect("sidecar") + .len(); // 4 dims * 4 bytes per append. assert_eq!(size_after_first, 16); @@ -10794,7 +1685,10 @@ mod tests { assert_eq!(migrated.chunks[0].vector, vec![0.25, 0.5, 0.75, 1.0]); assert_eq!(migrated.chunks[0].text, "legacy text"); // The pre-migration file must still be recoverable. - assert!(backup_path(&path).exists(), "v1 store should be kept as .bak"); + assert!( + backup_path(&path).exists(), + "v1 store should be kept as .bak" + ); // Reloading must now take the v2 path and produce the same vectors. let reloaded = block_on(load_vectors(&path)).expect("reload"); @@ -10824,9 +1718,10 @@ mod tests { let mut store = VectorStoreData::default(); let total = VECTOR_COMPACTION_MIN_SLOTS as usize + 8; - store.push_chunks( - (0..total).map(|index| vector_chunk(&format!("c{index}"), vec![index as f32, 0.0, 0.0, 0.0])), - ); + store + .push_chunks((0..total).map(|index| { + vector_chunk(&format!("c{index}"), vec![index as f32, 0.0, 0.0, 0.0]) + })); block_on(save_vectors(&path, &mut store)).expect("save"); assert_eq!(store.next_slot, total as u64); @@ -10836,7 +1731,9 @@ mod tests { block_on(save_vectors(&path, &mut store)).expect("save after delete"); assert_eq!(store.next_slot, survivors, "slots should be renumbered"); - let sidecar = fs::metadata(vector_data_path(&path)).expect("sidecar").len(); + let sidecar = fs::metadata(vector_data_path(&path)) + .expect("sidecar") + .len(); assert_eq!(sidecar, survivors * 4 * 4, "dead slots should be reclaimed"); let loaded = block_on(load_vectors(&path)).expect("load"); @@ -10887,7 +1784,9 @@ mod tests { block_on(save_vectors(&path, &mut store)).expect("save"); // Two embedded chunks at 4 dims; the parked one contributes nothing. - let sidecar = fs::metadata(vector_data_path(&path)).expect("sidecar").len(); + let sidecar = fs::metadata(vector_data_path(&path)) + .expect("sidecar") + .len(); assert_eq!(sidecar, 2 * 4 * 4); let loaded = block_on(load_vectors(&path)).expect("load"); @@ -10947,7 +1846,10 @@ mod tests { let migrated = block_on(load_vectors(&path)).expect("migrate"); - assert_eq!(migrated.dim, 4, "the majority width wins, not the first one"); + assert_eq!( + migrated.dim, 4, + "the majority width wins, not the first one" + ); assert_eq!(migrated.chunks.len(), 4, "nothing is discarded"); assert_eq!(migrated.embedded_count(), 3); assert_eq!(migrated.pending_reembed_count(), 1); @@ -11030,7 +1932,11 @@ mod tests { ); // Count beats width. assert_eq!( - majority_vector_dim(&[chunk(vec![0.0; 2]), chunk(vec![0.0; 2]), chunk(vec![0.0; 8])]), + majority_vector_dim(&[ + chunk(vec![0.0; 2]), + chunk(vec![0.0; 2]), + chunk(vec![0.0; 8]) + ]), 2 ); } @@ -11044,20 +1950,26 @@ mod tests { let mut store = VectorStoreData::default(); let total = VECTOR_COMPACTION_MIN_SLOTS as usize + 8; - store.push_chunks( - (0..total) - .map(|index| vector_chunk(&format!("c{index}"), vec![index as f32, 0.0, 0.0, 0.0])), - ); + store + .push_chunks((0..total).map(|index| { + vector_chunk(&format!("c{index}"), vec![index as f32, 0.0, 0.0, 0.0]) + })); store.push_chunks(vec![vector_chunk("parked", vec![1.0, 2.0])]); block_on(save_vectors(&path, &mut store)).expect("save"); - store.chunks + store + .chunks .retain(|chunk| chunk.needs_reembed || chunk.vector_slot % 8 == 0); let survivors = store.embedded_count(); block_on(save_vectors(&path, &mut store)).expect("save after delete"); - assert_eq!(store.next_slot, survivors, "slots renumber over embedded only"); - let sidecar = fs::metadata(vector_data_path(&path)).expect("sidecar").len(); + assert_eq!( + store.next_slot, survivors, + "slots renumber over embedded only" + ); + let sidecar = fs::metadata(vector_data_path(&path)) + .expect("sidecar") + .len(); assert_eq!(sidecar, survivors * 4 * 4); let loaded = block_on(load_vectors(&path)).expect("load"); @@ -11065,7 +1977,10 @@ mod tests { assert_eq!(loaded.pending_reembed_count(), 1, "parked chunk survives"); for chunk in loaded.chunks.iter().filter(|chunk| !chunk.needs_reembed) { let expected: f32 = chunk.capture_id.trim_start_matches('c').parse().unwrap(); - assert_eq!(chunk.vector[0], expected, "vectors stay matched to their text"); + assert_eq!( + chunk.vector[0], expected, + "vectors stay matched to their text" + ); } } @@ -11093,12 +2008,14 @@ mod tests { #[cfg(desktop)] #[test] fn download_filenames_decode_and_keep_extensions() { - let name = - file_name_from_url(&Url::parse("https://example.com/docs/My%20Report%202026.pdf").unwrap()); + let name = file_name_from_url( + &Url::parse("https://example.com/docs/My%20Report%202026.pdf").unwrap(), + ); assert_eq!(name, "My Report 2026.pdf"); - let stripped = - file_name_from_url(&Url::parse("https://example.com/a/b/paper.tar.gz?token=1").unwrap()); + let stripped = file_name_from_url( + &Url::parse("https://example.com/a/b/paper.tar.gz?token=1").unwrap(), + ); assert_eq!(stripped, "paper.tar.gz"); } @@ -11106,8 +2023,13 @@ mod tests { #[test] fn download_filenames_are_length_capped() { let long = "x".repeat(400); - let name = file_name_from_url(&Url::parse(&format!("https://example.com/{long}.bin")).unwrap()); - assert!(name.chars().count() <= 180, "got {} chars", name.chars().count()); + let name = + file_name_from_url(&Url::parse(&format!("https://example.com/{long}.bin")).unwrap()); + assert!( + name.chars().count() <= 180, + "got {} chars", + name.chars().count() + ); } #[cfg(desktop)] @@ -11131,7 +2053,11 @@ mod tests { window: None, }; // Mirrors restore_session_tabs' selection rule without needing a Tauri app. - let ids = session.tabs.iter().map(|tab| tab.id.clone()).collect::>(); + let ids = session + .tabs + .iter() + .map(|tab| tab.id.clone()) + .collect::>(); let active = ids .iter() .any(|id| *id == session.active_tab_id) @@ -11144,7 +2070,11 @@ mod tests { active_tab_id: "tab-gone".to_string(), ..session }; - let ids = stale.tabs.iter().map(|tab| tab.id.clone()).collect::>(); + let ids = stale + .tabs + .iter() + .map(|tab| tab.id.clone()) + .collect::>(); let active = ids .iter() .any(|id| *id == stale.active_tab_id) @@ -11237,21 +2167,30 @@ mod tests { let mut store = VectorStoreData::default(); for doc in eval_corpus() { let chunks = split_text(doc.body, 220, 40); - assert!(!chunks.is_empty(), "fixture {} produced no chunks", doc.capture_id); - store.push_chunks(chunks.into_iter().enumerate().map(|(index, text)| ChunkRecord { - id: uuid(), - vector: eval_embed(&text), - vector_slot: 0, - needs_reembed: false, - text, - collection_id: "hub-eval".to_string(), - capture_id: doc.capture_id.to_string(), - title: doc.title.to_string(), - url: doc.url.to_string(), - app_id: "browser".to_string(), - captured_at: format!("2026-07-{:02}T00:00:00Z", 10 + index), - chunk_index: index, - })); + assert!( + !chunks.is_empty(), + "fixture {} produced no chunks", + doc.capture_id + ); + store.push_chunks( + chunks + .into_iter() + .enumerate() + .map(|(index, text)| ChunkRecord { + id: uuid(), + vector: eval_embed(&text), + vector_slot: 0, + needs_reembed: false, + text, + collection_id: "hub-eval".to_string(), + capture_id: doc.capture_id.to_string(), + title: doc.title.to_string(), + url: doc.url.to_string(), + app_id: "browser".to_string(), + captured_at: format!("2026-07-{:02}T00:00:00Z", 10 + index), + chunk_index: index, + }), + ); } block_on(save_vectors(&path, &mut store)).expect("persist eval store"); @@ -11263,10 +2202,18 @@ mod tests { (reloaded, names) } - fn eval_top_ids(store: &VectorStoreData, names: &HashMap, question: &str, take: usize) -> Vec { + fn eval_top_ids( + store: &VectorStoreData, + names: &HashMap, + question: &str, + take: usize, + ) -> Vec { let query = eval_embed(question); let (hits, _) = rank_library_hits(&store.chunks, names, None, Some(&query), "", 20); - hits.into_iter().take(take).map(|hit| hit.capture_id).collect() + hits.into_iter() + .take(take) + .map(|hit| hit.capture_id) + .collect() } // The core retrieval contract: asking about a topic must surface the source that @@ -11279,8 +2226,14 @@ mod tests { let cases = [ ("When did quantum mechanics develop?", "quantum"), ("Who was the first Roman emperor?", "roman-empire"), - ("How do plants turn light into chemical energy?", "photosynthesis"), - ("What happens when a value's owner goes out of scope?", "rust-ownership"), + ( + "How do plants turn light into chemical energy?", + "photosynthesis", + ), + ( + "What happens when a value's owner goes out of scope?", + "rust-ownership", + ), ("black-body radiation problem", "quantum"), ("chlorophyll photons glucose", "photosynthesis"), ("borrow checker dangling references", "rust-ownership"), @@ -11294,7 +2247,11 @@ mod tests { failures.push(format!("{question:?} expected {expected}, got {top:?}")); } } - assert!(failures.is_empty(), "retrieval regressions:\n{}", failures.join("\n")); + assert!( + failures.is_empty(), + "retrieval regressions:\n{}", + failures.join("\n") + ); } #[test] @@ -11323,13 +2280,19 @@ mod tests { let (hits, examined) = rank_library_hits(&store.chunks, &names, None, Some(&query), "", 20); assert_eq!(examined, store.chunks.len(), "every chunk should be scored"); - let mut ids = hits.iter().map(|hit| hit.capture_id.clone()).collect::>(); + let mut ids = hits + .iter() + .map(|hit| hit.capture_id.clone()) + .collect::>(); ids.sort(); let unique = ids.iter().collect::>().len(); assert_eq!(ids.len(), unique, "a source must not appear twice: {ids:?}"); assert!(hits.len() <= eval_corpus().len()); // Grouping must report how many passages matched, not silently collapse them. - let quantum = hits.iter().find(|hit| hit.capture_id == "quantum").expect("quantum hit"); + let quantum = hits + .iter() + .find(|hit| hit.capture_id == "quantum") + .expect("quantum hit"); assert!(quantum.chunk_matches >= 1); assert_eq!(quantum.collection_name, "Eval hub"); assert_eq!(quantum.host, "en.wikipedia.org"); @@ -11344,9 +2307,18 @@ mod tests { let (limited, _) = rank_library_hits(&store.chunks, &names, None, Some(&query), "", 2); assert_eq!(limited.len(), 2, "limit must cap the result set"); - let (other_hub, _) = - rank_library_hits(&store.chunks, &names, Some("hub-missing"), Some(&query), "", 20); - assert!(other_hub.is_empty(), "scoping to another hub must exclude everything"); + let (other_hub, _) = rank_library_hits( + &store.chunks, + &names, + Some("hub-missing"), + Some(&query), + "", + 20, + ); + assert!( + other_hub.is_empty(), + "scoping to another hub must exclude everything" + ); } #[test] @@ -11402,7 +2374,7 @@ mod tests { #[test] fn chunking_handles_multibyte_text_without_splitting_characters() { - let body = "Æther — 私は研究します。".repeat(20); + let body = "— 私は研究します。".repeat(20); let chunks = split_text(&body, 40, 10); assert!(!chunks.is_empty()); @@ -11439,7 +2411,7 @@ mod tests { let migrated = block_on(load_vectors(&path)).expect("migrate"); - eprintln!( + diag_warn!( "legacy store: {before} chunks, widths {widths:?} -> dim {}, {} embedded, {} parked", migrated.dim, migrated.embedded_count(), @@ -11503,15 +2475,27 @@ mod tests { // the page the user is picturing gets buried under passing references to it. #[test] fn literal_search_ranks_title_matches_above_body_matches() { - let titled = chunk_for_search("a", "Quantum mechanics", "https://example.com/a", "unrelated"); - let mentioned = - chunk_for_search("b", "Cooking basics", "https://example.com/b", "quantum mechanics"); + let titled = chunk_for_search( + "a", + "Quantum mechanics", + "https://example.com/a", + "unrelated", + ); + let mentioned = chunk_for_search( + "b", + "Cooking basics", + "https://example.com/b", + "quantum mechanics", + ); let titled_score = literal_match_score("quantum mechanics", &titled); let mentioned_score = literal_match_score("quantum mechanics", &mentioned); assert!(titled_score > mentioned_score); - assert!(mentioned_score > 0.0, "body matches should still be findable"); + assert!( + mentioned_score > 0.0, + "body matches should still be findable" + ); } #[test] @@ -11607,7 +2591,7 @@ mod tests { #[test] fn stream_safe_len_respects_multibyte_boundaries() { - let text = "Æther çalışması — özet ModelCatalog { + let mut errors = Vec::new(); + let model_dirs = [ + paths.models_path.clone(), + paths.models_path.join("chat"), + paths.models_path.join("embeddings"), + ]; + for dir in &model_dirs { + if let Err(error) = fs::create_dir_all(dir) { + errors.push(format!( + "Could not create model directory {}: {error}", + dir.display() + )); + } + } + + let mut models = Vec::new(); + collect_gguf_models(&paths.models_path, &mut models); + if let Ok(dir) = env::var(AETHER_MODEL_DIR_ENV) { + let dir = PathBuf::from(dir); + collect_gguf_models(&dir, &mut models); + } + for var in [AETHER_CHAT_MODEL_ENV, AETHER_EMBEDDING_MODEL_ENV] { + match env_model_path(var) { + Ok(Some(path)) => models.push(path), + Ok(None) => {} + Err(error) => errors.push(error), + } + } + for (value, embedding) in [ + (settings.chat_model.as_deref(), false), + (settings.embedding_model.as_deref(), true), + ] { + if let Some(path) = value.and_then(|value| selected_direct_model_path(value, embedding)) { + models.push(path); + } + } + + models = dedupe_model_paths(models); + let embedding_model = pick_embedding_model(&models, settings); + let chat_model = pick_chat_model(&models, settings); + if models.is_empty() { + errors.push(format!( + "No local models found. Use Model Setup to install AiON MiST/Qwen3 Embedding and Gemma 4, add compatible GGUF models to {}, or set {AETHER_MODEL_DIR_ENV}.", + paths.models_path.display() + )); + } else { + if embedding_model.is_none() { + errors.push(format!( + "No embedding model selected. Put Qwen3 Embedding or another embedding GGUF in {} or set {AETHER_EMBEDDING_MODEL_ENV}.", + paths.models_path.join("embeddings").display() + )); + } + if chat_model.is_none() { + errors.push(format!( + "No chat GGUF selected. Put a Gemma chat model in {} or set {AETHER_CHAT_MODEL_ENV}.", + paths.models_path.join("chat").display() + )); + } + } + + ModelCatalog { + models, + chat_model, + embedding_model, + error: if errors.is_empty() { + None + } else { + Some(errors.join(" ")) + }, + } +} + +pub(crate) fn selected_direct_model_path(value: &str, embedding: bool) -> Option { + let value = value.trim(); + if value.is_empty() { + return None; + } + let path = PathBuf::from(value); + if selected_model_matches_kind(&path, embedding) { + Some(canonical_model_path(&path)) + } else { + None + } +} + +pub(crate) fn collect_gguf_models(root: &Path, models: &mut Vec) { + let mut stack = vec![(root.to_path_buf(), 0usize)]; + while let Some((dir, depth)) = stack.pop() { + if depth > 4 { + continue; + } + let Ok(entries) = fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push((path, depth + 1)); + } else if is_gguf_model(&path) { + models.push(path); + } + } + } +} + +pub(crate) fn default_models_path(app_data_dir: &Path) -> PathBuf { + // The repo-relative dev path is a compile-time string from the build + // machine; on a phone it would point at a nonexistent host filesystem. + if cfg!(all(debug_assertions, desktop)) { + project_models_path() + } else { + app_data_dir.join("aether-models") + } +} + +pub(crate) fn project_models_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .map(|path| path.join("aether-models")) + .unwrap_or_else(|| PathBuf::from("aether-models")) +} + +pub(crate) fn dedupe_model_paths(models: Vec) -> Vec { + let mut seen = HashSet::new(); + let mut deduped = Vec::new(); + for path in models { + let path = canonical_model_path(&path); + let key = path.display().to_string(); + if seen.insert(key) { + deduped.push(path); + } + } + deduped.sort_by_key(|path| model_label(path).to_lowercase()); + deduped +} + +pub(crate) fn env_model_path(var: &str) -> Cmd> { + let Ok(value) = env::var(var) else { + return Ok(None); + }; + let path = PathBuf::from(value.trim()); + let valid = match var { + AETHER_CHAT_MODEL_ENV => is_chat_model(&path), + AETHER_EMBEDDING_MODEL_ENV => is_embedding_model(&path), + _ => is_gguf_model(&path), + }; + if valid { + Ok(Some(path)) + } else { + Err(format!( + "{var} does not point to an existing local model: {}", + path.display() + )) + } +} + +pub(crate) fn pick_embedding_model( + models: &[PathBuf], + settings: &LocalModelSettings, +) -> Option { + if let Ok(Some(path)) = env_model_path(AETHER_EMBEDDING_MODEL_ENV) { + return Some(canonical_model_path(&path)); + } + if let Some(model) = settings + .embedding_model + .as_deref() + .and_then(|value| pick_selected_model(models, value, true)) + { + return Some(model); + } + models + .iter() + .filter(|path| is_embedding_model(path)) + .max_by_key(|path| embedding_model_score(path)) + .cloned() +} + +pub(crate) fn pick_chat_model( + models: &[PathBuf], + settings: &LocalModelSettings, +) -> Option { + if let Ok(Some(path)) = env_model_path(AETHER_CHAT_MODEL_ENV) { + return Some(canonical_model_path(&path)); + } + if let Some(model) = settings + .chat_model + .as_deref() + .and_then(|value| pick_selected_model(models, value, false)) + { + return Some(model); + } + pick_model_by_hints(models, &PREFERRED_CHAT_MODEL_HINTS, false).or_else(|| { + models + .iter() + .find(|path| !is_embedding_model_name(path)) + .cloned() + }) +} + +pub(crate) fn pick_selected_model( + models: &[PathBuf], + value: &str, + embedding: bool, +) -> Option { + let value = value.trim(); + if value.is_empty() { + return None; + } + let direct = PathBuf::from(value); + if selected_model_matches_kind(&direct, embedding) { + return Some(canonical_model_path(&direct)); + } + let normalized = value.to_lowercase(); + models + .iter() + .find(|path| { + let label = model_label(path); + path_to_model_value(path) == value + || label == value + || strip_gguf_extension(&label) == value + || label.to_lowercase().contains(&normalized) + }) + .filter(|path| selected_model_matches_kind(path, embedding)) + .cloned() +} + +pub(crate) fn pick_model_by_hints( + models: &[PathBuf], + hints: &[&str], + embedding: bool, +) -> Option { + for hint in hints { + let hint = hint.to_lowercase(); + if let Some(model) = models.iter().find(|path| { + let label = model_label(path).to_lowercase(); + label.contains(&hint) + && if embedding { + is_embedding_model(path) + } else { + is_chat_model(path) + } + }) { + return Some(model.clone()); + } + } + None +} + +pub(crate) fn embedding_model_score(path: &Path) -> i32 { + let label = model_label(path).to_lowercase(); + let mut score = 0; + if is_gguf_model(path) { + score += 1_000; + } + if label.contains("qwen3-embedding") { + score += 650; + } + if label.contains("bf16") { + score += 400; + } else if label.contains("f16") { + score += 300; + } else if label.contains("q8") { + score += 150; + } + score +} + +pub(crate) fn embedding_pooling_type(path: &Path) -> LlamaPoolingType { + if is_qwen3_embedding_model(path) { + LlamaPoolingType::Last + } else { + LlamaPoolingType::Mean + } +} + +pub(crate) fn embedding_attention_type(path: &Path) -> LlamaAttentionType { + if is_qwen3_embedding_model(path) { + LlamaAttentionType::Causal + } else { + LlamaAttentionType::Unspecified + } +} + +pub(crate) fn is_qwen3_embedding_model(path: &Path) -> bool { + let label = model_label(path).to_lowercase(); + label.contains("qwen3-embedding") +} + +pub(crate) fn qwen3_embedding_decode(path: &Path) -> bool { + is_qwen3_embedding_model(path) +} + +pub(crate) fn is_gguf_model(path: &Path) -> bool { + path.is_file() + && !is_mmproj_model(path) + && path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("gguf")) +} + +pub(crate) fn is_chat_model(path: &Path) -> bool { + is_gguf_model(path) && !is_embedding_model_name(path) +} + +pub(crate) fn selected_model_matches_kind(path: &Path, embedding: bool) -> bool { + if embedding { + is_embedding_model(path) + } else { + is_chat_model(path) + } +} + +pub(crate) fn is_embedding_model(path: &Path) -> bool { + is_gguf_model(path) && is_embedding_model_name(path) +} + +pub(crate) fn is_embedding_model_name(path: &Path) -> bool { + let label = model_label(path).to_lowercase(); + label.contains("embed") || label.contains("embedding") +} + +pub(crate) fn is_mmproj_model(path: &Path) -> bool { + model_label(path).to_lowercase().contains("mmproj") +} + +pub(crate) fn canonical_model_path(path: &Path) -> PathBuf { + fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()) +} + +pub(crate) fn path_to_model_value(path: &Path) -> String { + path.display().to_string() +} + +pub(crate) fn model_label(path: &Path) -> String { + path.file_name() + .and_then(|name| name.to_str()) + .map(strip_gguf_extension) + .unwrap_or_else(|| path.display().to_string()) +} + +/// Product name for user-facing status text. Filenames must stay in sync with +/// `managed_model_spec`; anything unmanaged falls back to its cleaned filename. +pub(crate) fn friendly_model_label(path: &Path) -> String { + match path.file_name().and_then(|name| name.to_str()) { + Some("gemma-4-E2B_q4_0-it.gguf") => "AiON LiTE".to_string(), + Some("gemma-4-E4B_q4_0-it.gguf") => "AiON WiSE".to_string(), + Some("Qwen3-Embedding-0.6B-Q8_0.gguf") => "AiON MiST".to_string(), + _ => model_label(path), + } +} + +pub(crate) fn strip_gguf_extension(value: &str) -> String { + value + .strip_suffix(".gguf") + .or_else(|| value.strip_suffix(".GGUF")) + .unwrap_or(value) + .to_string() +} + +pub(crate) fn chat_context_tokens() -> u32 { + // Phones get half the desktop window: the KV cache plus compute buffers + // for 6k context put a multi-GB model into zram-thrashing territory, + // which reads as a silent hang during prefill. + let default = if cfg!(mobile) { + 3072 + } else { + DEFAULT_CHAT_CONTEXT_TOKENS + }; + env::var(AETHER_LLM_CONTEXT_ENV) + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(default) + .clamp(1024, 65_536) +} + +pub(crate) fn chat_batch_token_limit() -> usize { + env::var(AETHER_LLM_BATCH_TOKENS_ENV) + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(DEFAULT_CHAT_BATCH_TOKENS) + .clamp(512, 8192) +} + +pub(crate) fn local_gpu_enabled() -> bool { + env_flag_enabled(AETHER_LLM_GPU_ENV, cfg!(target_os = "macos")) +} + +pub(crate) fn embedding_gpu_enabled() -> bool { + env_flag_enabled(AETHER_EMBED_GPU_ENV, false) +} + +pub(crate) fn env_flag_enabled(name: &str, default: bool) -> bool { + env::var(name).ok().map_or(default, |value| { + matches!(value.to_lowercase().as_str(), "1" | "true" | "yes" | "on") + }) +} + +pub(crate) fn embedding_batch_size() -> usize { + env::var(AETHER_EMBED_BATCH_ENV) + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(DEFAULT_EMBEDDING_BATCH_SIZE) + .clamp(1, 24) +} + +pub(crate) fn embedding_batch_token_limit() -> usize { + env::var(AETHER_EMBED_BATCH_TOKENS_ENV) + .ok() + .and_then(|value| value.parse::().ok()) + .unwrap_or(DEFAULT_EMBEDDING_BATCH_TOKENS) + .clamp(512, 8192) +} + +pub(crate) fn embedding_context_tokens(input_tokens: usize) -> u32 { + let needed = input_tokens.saturating_add(16).min(u32::MAX as usize) as u32; + DEFAULT_EMBEDDING_CONTEXT_TOKENS.max(needed).min(8192) +} + +pub(crate) fn auto_thread_count() -> i32 { + // Mobile keeps one core free instead of two: recent flagships (like the + // all-big-core Snapdragon 8 Elite) have no little cores to avoid, prefill + // is compute-bound, and the UI sits idle while AiON works. + let reserve = if cfg!(mobile) { 1 } else { 2 }; + std::thread::available_parallelism() + .map(|threads| threads.get().saturating_sub(reserve).clamp(2, 12) as i32) + .unwrap_or(6) +} + +pub(crate) fn normalize_embedding(values: &[f32]) -> Vec { + let norm = values + .iter() + .map(|value| (*value as f64) * (*value as f64)) + .sum::() + .sqrt(); + if norm <= f64::EPSILON { + return values.to_vec(); + } + values + .iter() + .map(|value| (*value as f64 / norm) as f32) + .collect() +} + +// Phones prefill on CPU, so prompt length is the ask latency. The mobile +// budget (5 sources x ~1100 chars + system + question) fits the 2048-token +// prompt window, where desktop's 8 full chunks would overflow it and get +// front-truncated — losing the system message and top-ranked sources while +// still paying full prefill cost. diff --git a/src-tauri/src/model_downloads.rs b/src-tauri/src/model_downloads.rs new file mode 100644 index 0000000..6b11ffb --- /dev/null +++ b/src-tauri/src/model_downloads.rs @@ -0,0 +1,380 @@ +//! Fetching managed GGUF models from Hugging Face, with resumable progress. + +use super::*; + +/// One progress tick for a managed model download. A struct rather than six +/// positional arguments: at the call sites the bare numbers read as +/// `0, Some(spec.expected_bytes), overall_downloaded, overall_total`, which is +/// impossible to check by eye. +pub(crate) struct ModelDownloadStage<'a> { + pub(crate) status: &'a str, + pub(crate) downloaded_bytes: u64, + pub(crate) total_bytes: Option, + pub(crate) overall_downloaded_bytes: u64, + pub(crate) overall_total_bytes: Option, + pub(crate) message: Option, +} + +pub(crate) async fn download_managed_models( + app: &AppHandle, + state: &State<'_, Backend>, + input: DownloadModelsInput, +) -> Cmd<()> { + let specs = selected_model_downloads(&state.paths, &input)?; + let hf_token = input + .hf_token + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or_else(huggingface_token); + let overall_total = specs + .iter() + .map(|spec| spec.expected_bytes) + .reduce(|first, second| first.saturating_add(second)); + let mut overall_downloaded = 0u64; + + for spec in &specs { + if let Some(existing_bytes) = + completed_model_bytes(&spec.destination, spec.expected_bytes).await + { + overall_downloaded = overall_downloaded.saturating_add(existing_bytes); + emit_model_download_progress( + app, + spec, + ModelDownloadStage { + status: "skipped", + downloaded_bytes: existing_bytes, + total_bytes: Some(spec.expected_bytes), + overall_downloaded_bytes: overall_downloaded, + overall_total_bytes: overall_total, + message: Some("Already installed".to_string()), + }, + ); + continue; + } + + emit_model_download_progress( + app, + spec, + ModelDownloadStage { + status: "queued", + downloaded_bytes: 0, + total_bytes: Some(spec.expected_bytes), + overall_downloaded_bytes: overall_downloaded, + overall_total_bytes: overall_total, + message: Some("Preparing download".to_string()), + }, + ); + + match download_model_file( + app, + &state.client, + spec, + overall_downloaded, + overall_total, + hf_token.as_deref(), + ) + .await + { + Ok(downloaded_bytes) => { + overall_downloaded = overall_downloaded.saturating_add(downloaded_bytes); + emit_model_download_progress( + app, + spec, + ModelDownloadStage { + status: "complete", + downloaded_bytes, + total_bytes: Some(downloaded_bytes), + overall_downloaded_bytes: overall_downloaded, + overall_total_bytes: overall_total, + message: Some("Installed".to_string()), + }, + ); + } + Err(error) => { + emit_model_download_progress( + app, + spec, + ModelDownloadStage { + status: "error", + downloaded_bytes: 0, + total_bytes: Some(spec.expected_bytes), + overall_downloaded_bytes: overall_downloaded, + overall_total_bytes: overall_total, + message: Some(error.clone()), + }, + ); + return Err(error); + } + } + } + + persist_downloaded_model_selection(&state.paths, &specs).await +} + +pub(crate) fn selected_model_downloads( + paths: &DataPaths, + input: &DownloadModelsInput, +) -> Cmd> { + let mut specs = vec![managed_model_spec(paths, "mist")?]; + let mut selected = HashSet::new(); + + for model in &input.chat_models { + let normalized = model.trim().to_lowercase(); + if normalized.is_empty() { + continue; + } + if !selected.insert(normalized.clone()) { + continue; + } + specs.push(managed_model_spec(paths, &normalized)?); + } + + Ok(specs) +} + +pub(crate) fn managed_model_spec(paths: &DataPaths, id: &str) -> Cmd { + match id { + "mist" => Ok(ModelDownloadSpec { + id: "mist", + label: "AiON MiST", + repository: "Qwen/Qwen3-Embedding-0.6B-GGUF", + revision: "370f27d7550e0def9b39c1f16d3fbaa13aa67728", + filename: "Qwen3-Embedding-0.6B-Q8_0.gguf", + destination: paths + .models_path + .join("embeddings") + .join("Qwen3-Embedding-0.6B-GGUF") + .join("Qwen3-Embedding-0.6B-Q8_0.gguf"), + expected_bytes: 639_150_592, + }), + "lite" => Ok(ModelDownloadSpec { + id: "lite", + label: "AiON LiTE", + repository: "google/gemma-4-E2B-it-qat-q4_0-gguf", + revision: "1894d1fc0a19d86697abd40483f5983c867df03f", + filename: "gemma-4-E2B_q4_0-it.gguf", + destination: paths + .models_path + .join("chat") + .join("gemma-4-E2B-it-qat-q4_0-gguf") + .join("gemma-4-E2B_q4_0-it.gguf"), + expected_bytes: 3_349_514_112, + }), + "wise" => Ok(ModelDownloadSpec { + id: "wise", + label: "AiON WiSE", + repository: "google/gemma-4-E4B-it-qat-q4_0-gguf", + revision: "bb3b92e6f031fa438b409f898dd9f14f499a0cb0", + filename: "gemma-4-E4B_q4_0-it.gguf", + destination: paths + .models_path + .join("chat") + .join("gemma-4-E4B-it-qat-q4_0-gguf") + .join("gemma-4-E4B_q4_0-it.gguf"), + expected_bytes: 5_154_939_136, + }), + _ => Err(format!("Unknown AiON model selection: {id}")), + } +} + +pub(crate) async fn completed_model_bytes(path: &Path, expected_bytes: u64) -> Option { + let metadata = tokio::fs::metadata(path).await.ok()?; + let len = metadata.len(); + if metadata.is_file() && len == expected_bytes { + Some(len) + } else { + None + } +} + +pub(crate) async fn download_model_file( + app: &AppHandle, + client: &Client, + spec: &ModelDownloadSpec, + overall_base_bytes: u64, + overall_total_bytes: Option, + hf_token: Option<&str>, +) -> Cmd { + let parent = spec + .destination + .parent() + .ok_or_else(|| format!("Invalid model destination: {}", spec.destination.display()))?; + tokio::fs::create_dir_all(parent).await.map_err(|error| { + format!( + "Could not create model directory {}: {error}", + parent.display() + ) + })?; + + let temp_path = spec + .destination + .with_file_name(format!("{}.part", spec.filename)); + let _ = tokio::fs::remove_file(&temp_path).await; + + let mut request = client.get(spec.source_url()); + if let Some(token) = hf_token { + request = request.bearer_auth(token); + } + + let mut response = request + .send() + .await + .map_err(|error| format!("Could not reach Hugging Face for {}: {error}", spec.label))?; + let status = response.status(); + if !status.is_success() { + return Err(huggingface_download_error(spec, status.as_u16())); + } + + let total_bytes = response.content_length().or(Some(spec.expected_bytes)); + let mut file = tokio::fs::File::create(&temp_path).await.map_err(|error| { + format!( + "Could not create temporary model file {}: {error}", + temp_path.display() + ) + })?; + let mut downloaded_bytes = 0u64; + let mut last_emit = Instant::now(); + + emit_model_download_progress( + app, + spec, + ModelDownloadStage { + status: "downloading", + downloaded_bytes, + total_bytes, + overall_downloaded_bytes: overall_base_bytes, + overall_total_bytes, + message: Some("Downloading from Hugging Face".to_string()), + }, + ); + + while let Some(chunk) = response + .chunk() + .await + .map_err(|error| format!("Download interrupted for {}: {error}", spec.label))? + { + file.write_all(&chunk) + .await + .map_err(|error| format!("Could not write {}: {error}", spec.filename))?; + downloaded_bytes = downloaded_bytes.saturating_add(chunk.len() as u64); + + if last_emit.elapsed() >= Duration::from_millis(160) { + emit_model_download_progress( + app, + spec, + ModelDownloadStage { + status: "downloading", + downloaded_bytes, + total_bytes, + overall_downloaded_bytes: overall_base_bytes.saturating_add(downloaded_bytes), + overall_total_bytes, + message: None, + }, + ); + last_emit = Instant::now(); + } + } + + file.flush() + .await + .map_err(|error| format!("Could not finalize {}: {error}", spec.filename))?; + drop(file); + + if let Some(total) = total_bytes { + if downloaded_bytes != total { + let _ = tokio::fs::remove_file(&temp_path).await; + return Err(format!( + "Downloaded {} bytes for {}, expected {}.", + downloaded_bytes, spec.label, total + )); + } + } + + let _ = tokio::fs::remove_file(&spec.destination).await; + tokio::fs::rename(&temp_path, &spec.destination) + .await + .map_err(|error| { + format!( + "Could not move {} into {}: {error}", + temp_path.display(), + spec.destination.display() + ) + })?; + + Ok(downloaded_bytes) +} + +pub(crate) async fn persist_downloaded_model_selection( + paths: &DataPaths, + specs: &[ModelDownloadSpec], +) -> Cmd<()> { + let mut settings = load_settings(&paths.settings_path).await?; + if let Some(embedding) = specs.iter().find(|spec| spec.id == "mist") { + settings.local_model.embedding_model = Some(embedding.destination.display().to_string()); + } + if let Some(chat) = specs + .iter() + .rev() + .find(|spec| spec.id == "lite" || spec.id == "wise") + { + settings.local_model.chat_model = Some(chat.destination.display().to_string()); + } + save_json(&paths.settings_path, &settings).await +} + +pub(crate) fn huggingface_token() -> Option { + [ + HF_TOKEN_ENV, + HUGGINGFACE_HUB_TOKEN_ENV, + HUGGING_FACE_HUB_TOKEN_ENV, + ] + .iter() + .find_map(|var| env::var(var).ok()) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + +pub(crate) fn huggingface_download_error(spec: &ModelDownloadSpec, status: u16) -> String { + let auth_hint = if status == 401 || status == 403 { + format!( + " Accept the model terms on Hugging Face, then paste a Hugging Face read token in setup. Advanced users can also launch ÆTHER with {HF_TOKEN_ENV} or {HUGGINGFACE_HUB_TOKEN_ENV} set." + ) + } else { + String::new() + }; + format!( + "Could not download {} from official Hugging Face source {}/{} ({status}).{}", + spec.label, spec.repository, spec.filename, auth_hint + ) +} + +pub(crate) fn emit_model_download_progress( + app: &AppHandle, + spec: &ModelDownloadSpec, + stage: ModelDownloadStage<'_>, +) { + let ModelDownloadStage { + status, + downloaded_bytes, + total_bytes, + overall_downloaded_bytes, + overall_total_bytes, + message, + } = stage; + let _ = app.emit( + AETHER_MODEL_DOWNLOAD_PROGRESS_EVENT, + ModelDownloadProgress { + id: spec.id.to_string(), + label: spec.label.to_string(), + filename: spec.filename.to_string(), + status: status.to_string(), + downloaded_bytes, + total_bytes, + overall_downloaded_bytes, + overall_total_bytes, + message, + }, + ); +} diff --git a/src-tauri/src/retrieval.rs b/src-tauri/src/retrieval.rs new file mode 100644 index 0000000..8010ed9 --- /dev/null +++ b/src-tauri/src/retrieval.rs @@ -0,0 +1,457 @@ +//! Library and hub search, plus hub suggestion for a captured page. + +use super::*; + +pub(crate) async fn search_collection( + state: &State<'_, Backend>, + input: SearchCollectionInput, +) -> Cmd> { + let query = input.query.trim().to_string(); + if query.is_empty() { + return Ok(Vec::new()); + } + get_collection(&state.paths.library_path, &input.collection_id).await?; + let settings = load_settings(&state.paths.settings_path).await?; + let query_vector = local_embed_query(state, &settings, query).await?; + with_vectors_read(state, |vectors| { + let mut scored = vectors + .chunks + .iter() + .filter(|chunk| chunk.collection_id == input.collection_id) + .map(|chunk| (cosine_distance(&query_vector, &chunk.vector), chunk)) + .collect::>(); + scored.sort_by(|left, right| left.0.partial_cmp(&right.0).unwrap_or(Ordering::Equal)); + scored.truncate(input.limit.unwrap_or(8)); + scored + .into_iter() + .map(|(score, chunk)| SearchResult { + score, + id: chunk.id.clone(), + collection_id: chunk.collection_id.clone(), + capture_id: chunk.capture_id.clone(), + app_id: chunk.app_id.clone(), + title: chunk.title.clone(), + url: chunk.url.clone(), + captured_at: chunk.captured_at.clone(), + chunk_index: chunk.chunk_index, + text: chunk.text.clone(), + }) + .collect::>() + }) + .await +} + +// Library search groups by capture, not by chunk. The chunk-level results that +// power retrieval are the wrong shape for a person: eight hits from one long page +// reads as eight sources. One row per source, with its best-matching passage and a +// count of how many passages matched, is what someone scanning results wants. +pub(crate) async fn search_library( + state: &State<'_, Backend>, + input: SearchLibraryInput, +) -> Cmd { + let query = input.query.trim().to_string(); + if query.is_empty() { + return Ok(LibrarySearchResult { + query, + hits: Vec::new(), + mode: "semantic".to_string(), + searched_chunks: 0, + }); + } + + let library = load_library(&state.paths.library_path).await?; + let collection_names = library + .collections + .iter() + .map(|collection| (collection.id.clone(), collection.name.clone())) + .collect::>(); + if let Some(collection_id) = input.collection_id.as_deref() { + get_collection(&state.paths.library_path, collection_id).await?; + } + + let settings = load_settings(&state.paths.settings_path).await?; + let limit = input.limit.unwrap_or(20).clamp(1, 60); + + // Without an embedding model there is nothing to compare vectors against, so + // fall back to literal matching rather than failing. Search staying usable with + // no models installed is the difference between a browsable library and a + // library you can only guess at. + let query_vector = local_embed_query(state, &settings, query.clone()) + .await + .ok(); + let mode = if query_vector.is_some() { + "semantic" + } else { + "literal" + }; + let needle = query.to_lowercase(); + + let scope = input.collection_id.clone(); + let (hits, searched_chunks) = with_vectors_read(state, |vectors| { + rank_library_hits( + &vectors.chunks, + &collection_names, + scope.as_deref(), + query_vector.as_deref(), + &needle, + limit, + ) + }) + .await?; + + Ok(LibrarySearchResult { + query, + hits, + mode: mode.to_string(), + searched_chunks, + }) +} + +// Pure ranking core, split out from search_library so the retrieval contract can be +// tested without a Tauri app or a 640 MB embedding model. Everything model-independent +// about search quality — scoping, per-capture grouping, ordering, limits — lives here. +pub(crate) fn rank_library_hits( + chunks: &[ChunkRecord], + collection_names: &HashMap, + scope: Option<&str>, + query_vector: Option<&[f32]>, + needle: &str, + limit: usize, +) -> (Vec, usize) { + // map_or rather than is_none_or: the latter needs Rust 1.82 and this crate + // declares MSRV 1.77.2. + let scoped = chunks + .iter() + .filter(|chunk| scope.map_or(true, |id| chunk.collection_id == id)); + + let mut best: HashMap = HashMap::new(); + let mut examined = 0_usize; + + for chunk in scoped { + examined += 1; + let score = match query_vector { + Some(vector) => semantic_score_from_distance(cosine_distance(vector, &chunk.vector)), + None => literal_match_score(needle, chunk), + }; + if score <= 0.0 { + continue; + } + + match best.get_mut(&chunk.capture_id) { + Some(existing) => { + existing.chunk_matches += 1; + // One row per source shows its *best* passage, so a later weaker + // chunk must not overwrite a stronger earlier one. + if score > existing.score { + existing.score = score; + existing.excerpt = semantic_trail_excerpt(&chunk.text, 240); + } + } + None => { + best.insert( + chunk.capture_id.clone(), + LibrarySearchHit { + capture_id: chunk.capture_id.clone(), + collection_id: chunk.collection_id.clone(), + collection_name: collection_names + .get(&chunk.collection_id) + .cloned() + .unwrap_or_else(|| "Unknown hub".to_string()), + title: chunk.title.clone(), + url: chunk.url.clone(), + host: get_tab_host(&chunk.url), + captured_at: chunk.captured_at.clone(), + excerpt: semantic_trail_excerpt(&chunk.text, 240), + score, + chunk_matches: 1, + }, + ); + } + } + } + + let mut hits = best.into_values().collect::>(); + hits.sort_by(|left, right| { + right + .score + .partial_cmp(&left.score) + .unwrap_or(Ordering::Equal) + .then_with(|| right.captured_at.cmp(&left.captured_at)) + // Final tiebreak keeps output stable: HashMap iteration order is not. + .then_with(|| left.capture_id.cmp(&right.capture_id)) + }); + hits.truncate(limit); + (hits, examined) +} + +// Literal scoring for the no-embedding-model path. Title and host matches outrank +// body matches, because someone typing a remembered name wants that page first. +pub(crate) fn literal_match_score(needle: &str, chunk: &ChunkRecord) -> f64 { + if needle.is_empty() { + return 0.0; + } + let mut score = 0.0_f64; + if chunk.title.to_lowercase().contains(needle) { + score += 70.0; + } + if chunk.url.to_lowercase().contains(needle) { + score += 20.0; + } + if chunk.text.to_lowercase().contains(needle) { + score += 25.0; + } + score.min(100.0) +} + +#[tauri::command] +pub(crate) async fn aether_search_library( + state: State<'_, Backend>, + input: SearchLibraryInput, +) -> Cmd { + search_library(&state, input).await +} + +#[derive(Clone)] +pub(crate) struct SemanticTrailChunkCandidate { + pub(crate) chunk: ChunkRecord, + pub(crate) collection_name: String, + pub(crate) score: SemanticTrailScoreBreakdown, + pub(crate) reasons: Vec, +} + +#[derive(Clone)] +pub(crate) struct FlowSourceCandidate { + pub(crate) capture: CaptureSummary, + pub(crate) collection_name: String, + pub(crate) vector: Vec, + pub(crate) excerpt: String, +} + +pub(crate) struct FlowEdgeCandidate { + pub(crate) from: String, + pub(crate) to: String, + pub(crate) weight: f64, +} + +pub(crate) async fn semantic_trail_generate( + state: &State<'_, Backend>, + input: SemanticTrailInput, +) -> Cmd { + let limit = input + .limit + .unwrap_or(DEFAULT_SEMANTIC_TRAIL_LIMIT) + .clamp(1, MAX_SEMANTIC_TRAIL_LIMIT); + let explicit_query = input + .query + .as_deref() + .map(str::trim) + .filter(|query| !query.is_empty()); + let (root, visible_query, embedding_query, root_url_key) = if let Some(query) = explicit_query { + ( + SemanticTrailRoot { + title: query.to_string(), + url: String::new(), + host: String::new(), + excerpt: "Custom Focus lens matching captured sources across your knowledge hubs." + .to_string(), + }, + query.to_string(), + query.to_string(), + None, + ) + } else { + let active_tab = { + let tabs = lock_tabs(state)?; + if tabs.dashboard_open { + return Err("Open a web page before building Flow.".to_string()); + } + tabs.active_tab() + .cloned() + .ok_or_else(|| "No active browser tab.".to_string())? + }; + let captured = extract_readable_active_page(state, &active_tab).await?; + let root_host = get_tab_host(&captured.url); + let root_url_key = normalize_capture_url_key(&captured.url); + ( + SemanticTrailRoot { + title: captured.title.clone(), + url: captured.url.clone(), + host: root_host, + excerpt: semantic_trail_excerpt(&captured.text, 420), + }, + captured.title.clone(), + semantic_trail_default_query(&captured), + Some(root_url_key), + ) + }; + + let library = load_library(&state.paths.library_path).await?; + let collection_names = library + .collections + .iter() + .map(|collection| (collection.id.clone(), collection.name.clone())) + .collect::>(); + let root_collection_ids = root_url_key + .as_deref() + .map(|key| { + library + .captures + .iter() + .filter(|capture| normalize_capture_url_key(&capture.url) == key) + .map(|capture| capture.collection_id.clone()) + .collect::>() + }) + .unwrap_or_default(); + let chunks = with_vectors_read(state, |vectors| vectors.chunks.clone()).await?; + + if chunks.is_empty() { + return Ok(SemanticTrailResult { + query: visible_query, + generated_at: now(), + root, + items: Vec::new(), + edges: Vec::new(), + }); + } + + let settings = load_settings(&state.paths.settings_path).await?; + let query_vector = local_embed_query(state, &settings, embedding_query).await?; + + let mut candidates = chunks + .into_iter() + .filter_map(|chunk| { + let distance = cosine_distance(&query_vector, &chunk.vector); + if !distance.is_finite() { + return None; + } + let same_collection = root_collection_ids.contains(&chunk.collection_id); + let score = semantic_trail_score_breakdown(distance, &chunk.captured_at); + if score.semantic < SEMANTIC_TRAIL_MIN_SCORE { + return None; + } + let reasons = semantic_trail_reasons(&score, same_collection); + let collection_name = collection_names + .get(&chunk.collection_id) + .cloned() + .unwrap_or_else(|| "Knowledge Hub".to_string()); + Some(SemanticTrailChunkCandidate { + chunk, + collection_name, + score, + reasons, + }) + }) + .collect::>(); + + candidates.sort_by(|left, right| { + right + .score + .total + .partial_cmp(&left.score.total) + .unwrap_or(Ordering::Equal) + .then_with(|| { + right + .score + .semantic + .partial_cmp(&left.score.semantic) + .unwrap_or(Ordering::Equal) + }) + }); + + let items = semantic_trail_items(candidates, limit); + let edges = semantic_trail_edges(&root, &items); + + Ok(SemanticTrailResult { + query: visible_query, + generated_at: now(), + root, + items, + edges, + }) +} + +// Rank the user's hubs against the active page so capture can silently pre-select the best +// home for it. Best-effort: any reason we cannot produce a confident match returns Ok(None) +// rather than an error, so a failed suggestion never blocks or interrupts capturing. +pub(crate) async fn suggest_capture_hub( + state: &State<'_, Backend>, +) -> Cmd> { + let active_tab = { + let tabs = lock_tabs(state)?; + if tabs.dashboard_open { + return Ok(None); + } + match tabs.active_tab().cloned() { + Some(tab) => tab, + None => return Ok(None), + } + }; + + let captured = match extract_readable_active_page(state, &active_tab).await { + Ok(page) => page, + Err(_) => return Ok(None), + }; + + let library = load_library(&state.paths.library_path).await?; + if library.collections.is_empty() { + return Ok(None); + } + let chunks = with_vectors_read(state, |vectors| vectors.chunks.clone()).await?; + if chunks.is_empty() { + return Ok(None); + } + + let settings = load_settings(&state.paths.settings_path).await?; + let embedding_query = semantic_trail_default_query(&captured); + let query_vector = match local_embed_query(state, &settings, embedding_query).await { + Ok(vector) => vector, + Err(_) => return Ok(None), + }; + + // A hub is a strong home for this page if it already holds a source whose meaning is + // close to it, so score each hub by its single closest chunk. + let mut best_by_collection: HashMap = HashMap::new(); + for chunk in &chunks { + let distance = cosine_distance(&query_vector, &chunk.vector); + if !distance.is_finite() { + continue; + } + let semantic = semantic_score_from_distance(distance); + let entry = best_by_collection + .entry(chunk.collection_id.clone()) + .or_insert((0.0, String::new())); + if semantic > entry.0 { + entry.0 = semantic; + entry.1 = chunk.title.clone(); + } + } + + let Some((collection_id, (confidence, sample_title))) = + best_by_collection.into_iter().max_by(|left, right| { + left.1 + .0 + .partial_cmp(&right.1 .0) + .unwrap_or(Ordering::Equal) + }) + else { + return Ok(None); + }; + + if confidence < CAPTURE_SUGGEST_MIN_SCORE { + return Ok(None); + } + + let collection_name = library + .collections + .iter() + .find(|collection| collection.id == collection_id) + .map(|collection| collection.name.clone()) + .unwrap_or_else(|| "Knowledge Hub".to_string()); + + Ok(Some(CaptureHubSuggestion { + collection_id, + collection_name, + confidence: round_score(confidence), + sample_title, + })) +} diff --git a/src-tauri/src/retrieval_scoring.rs b/src-tauri/src/retrieval_scoring.rs new file mode 100644 index 0000000..e14ab70 --- /dev/null +++ b/src-tauri/src/retrieval_scoring.rs @@ -0,0 +1,193 @@ +//! Ranking a query against captured chunks: the current page as a pseudo-source, +//! semantic ranking, and the lexical fallback used with no embedding model. + +use super::*; + +pub(crate) fn capture_chunk_settings( + _paths: &DataPaths, + _settings: &UserSettings, +) -> (usize, usize) { + (DEFAULT_CAPTURE_CHUNK_SIZE, DEFAULT_CAPTURE_CHUNK_OVERLAP) +} + +pub(crate) fn current_page_search_result( + captured: &CapturedPage, + collection_id: Option<&str>, + chunk_index: usize, + score: f64, + text: String, +) -> SearchResult { + SearchResult { + id: format!("current-{}", uuid()), + collection_id: collection_id + .map(ToString::to_string) + .unwrap_or_else(|| "current-page".to_string()), + capture_id: "current-page".to_string(), + app_id: "browser".to_string(), + title: captured.title.clone(), + url: captured.url.clone(), + captured_at: now(), + chunk_index, + text, + score, + } +} + +// The current page isn't pre-indexed like a knowledge hub, so rank its chunks at +// ask-time. We mirror the hub's semantic retrieval (embed the query + chunks, rank +// by cosine distance, keep the best matches) and fall back to lexical scoring only +// when no embedding model is available. Returning several chunks instead of the +// single best is what lets the model actually answer: dedupe_citations later merges +// these same-URL chunks into one context-dense citation, just like the hub. +pub(crate) async fn current_page_citations( + state: &State<'_, Backend>, + settings: &UserSettings, + captured: CapturedPage, + prompt: &str, + collection_id: Option<&str>, + limit: usize, +) -> Vec { + if limit == 0 { + return Vec::new(); + } + let (chunk_size, chunk_overlap) = capture_chunk_settings(&state.paths, settings); + let chunks = split_text(&captured.text, chunk_size, chunk_overlap); + if chunks.is_empty() { + return Vec::new(); + } + + if let Some(ranked) = semantic_rank_chunks(state, settings, &chunks, prompt, limit).await { + return ranked + .into_iter() + .map(|(index, distance)| { + current_page_search_result( + &captured, + collection_id, + index, + distance, + chunks[index].clone(), + ) + }) + .collect(); + } + + // Lexical fallback (no embedding model): keep the highest-scoring chunks. + let mut scored = chunks + .iter() + .enumerate() + .map(|(index, text)| (lexical_relevance_score(text, prompt), index)) + .filter(|(score, _)| *score > 0.0) + .collect::>(); + scored.sort_by(|left, right| right.0.partial_cmp(&left.0).unwrap_or(Ordering::Equal)); + + if scored.is_empty() { + // Nothing matched lexically — anchor on the start of the page rather than + // returning nothing. + let first = chunks.into_iter().next().unwrap_or_default(); + return vec![current_page_search_result( + &captured, + collection_id, + 0, + 0.0, + first, + )]; + } + + scored + .into_iter() + .take(limit) + .map(|(score, index)| { + current_page_search_result( + &captured, + collection_id, + index, + score, + chunks[index].clone(), + ) + }) + .collect() +} + +// Embed the query alongside the page chunks in one batch, then order chunk indices +// by ascending cosine distance. Returns None when embedding is unavailable so the +// caller can fall back to lexical scoring. +pub(crate) async fn semantic_rank_chunks( + state: &State<'_, Backend>, + settings: &UserSettings, + chunks: &[String], + prompt: &str, + limit: usize, +) -> Option> { + let mut inputs = Vec::with_capacity(chunks.len() + 1); + inputs.push(embedding_query_input(&state.paths, settings, prompt)); + inputs.extend(chunks.iter().cloned()); + + let embeddings = local_embed(state, settings, inputs).await.ok()?; + if embeddings.len() != chunks.len() + 1 { + return None; + } + + let query_vector = &embeddings[0]; + let mut scored = embeddings[1..] + .iter() + .enumerate() + .map(|(index, vector)| (cosine_distance(query_vector, vector), index)) + .collect::>(); + scored.sort_by(|left, right| left.0.partial_cmp(&right.0).unwrap_or(Ordering::Equal)); + Some( + scored + .into_iter() + .take(limit) + .map(|(distance, index)| (index, distance)) + .collect(), + ) +} + +pub(crate) fn lexical_relevance_score(text: &str, query: &str) -> f64 { + let terms = query_terms(query); + if terms.is_empty() { + return 0.0; + } + let haystack = text.to_lowercase(); + terms + .iter() + .map(|term| lexical_term_score(&haystack, term)) + .sum() +} + +pub(crate) fn lexical_term_score(haystack: &str, term: &str) -> f64 { + let occurrences = haystack.matches(term).count(); + if occurrences > 0 { + // Reward repeated mentions so a chunk that actually discusses the term beats + // one that merely name-drops it once. Without this, every matching chunk ties + // and the tie-break is arbitrary. + return 2.0 + (term.len() as f64 / 10.0) + (occurrences.saturating_sub(1) as f64) * 0.5; + } + let stem_len = term.len().min(6); + if stem_len >= 5 && haystack.contains(&term[..stem_len]) { + return 1.25 + (stem_len as f64 / 12.0); + } + if let Some(singular) = term.strip_suffix('s') { + if singular.len() >= 5 && haystack.contains(singular) { + return 1.5 + (singular.len() as f64 / 12.0); + } + } + 0.0 +} + +pub(crate) fn query_terms(query: &str) -> Vec { + let stopwords = [ + "a", "an", "and", "are", "as", "at", "be", "by", "can", "do", "does", "for", "from", "how", + "i", "in", "is", "it", "me", "most", "of", "on", "or", "should", "the", "this", "to", + "was", "were", "what", "when", "where", "which", "who", "why", "with", + ]; + let stopwords = stopwords.into_iter().collect::>(); + let mut seen = HashSet::new(); + query + .split(|character: char| !character.is_alphanumeric()) + .map(str::trim) + .map(str::to_lowercase) + .filter(|term| term.len() > 2 && !stopwords.contains(term.as_str())) + .filter(|term| seen.insert(term.clone())) + .collect() +} diff --git a/src-tauri/src/store.rs b/src-tauri/src/store.rs new file mode 100644 index 0000000..23a650f --- /dev/null +++ b/src-tauri/src/store.rs @@ -0,0 +1,131 @@ +//! Durable store IO. Every write is temp-then-rename with a one-generation `.bak` +//! beside it, and an unreadable store is quarantined rather than overwritten — a +//! corrupt file must never cost the user their library. + +use super::*; + +pub(crate) fn backup_path(path: &Path) -> PathBuf { + let mut name = path.file_name().unwrap_or_default().to_os_string(); + name.push(BACKUP_SUFFIX); + path.with_file_name(name) +} + +pub(crate) fn temp_write_path(path: &Path) -> PathBuf { + let mut name = path.file_name().unwrap_or_default().to_os_string(); + name.push(TEMP_WRITE_SUFFIX); + path.with_file_name(name) +} + +pub(crate) async fn ensure_parent_dir(path: &Path) -> Cmd<()> { + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent) + .await + .map_err(|error| error.to_string())?; + } + Ok(()) +} + +// Writes bytes to a sibling temp file, fsyncs it, then renames it over the target. +// `rotate` additionally renames the previous good file to `.bak` first, so a +// crash can never leave the store both truncated and without a recoverable copy. +// +// Both renames are atomic within a directory, so the target is only ever the old +// complete file or the new complete file. The gap where the target is momentarily +// absent is covered by the backup, which `read_json_or_default` falls back to. +pub(crate) async fn write_bytes_atomically(path: &Path, bytes: &[u8], rotate: bool) -> Cmd<()> { + ensure_parent_dir(path).await?; + let temp = temp_write_path(path); + + // Scope the handle so it is flushed and closed before the rename. + { + let mut file = tokio::fs::File::create(&temp) + .await + .map_err(|error| error.to_string())?; + file.write_all(bytes) + .await + .map_err(|error| error.to_string())?; + // Without this the rename can land before the data does, which is exactly + // the truncated-store case this function exists to prevent. + file.sync_all().await.map_err(|error| error.to_string())?; + } + + if rotate && tokio::fs::try_exists(path).await.unwrap_or(false) { + let backup = backup_path(path); + if let Err(error) = tokio::fs::rename(path, &backup).await { + // A missing backup is recoverable; failing the whole save is not. + diag_warn!("could not rotate backup for {}: {error}", path.display()); + } + } + + tokio::fs::rename(&temp, path) + .await + .map_err(|error| error.to_string()) +} + +pub(crate) async fn write_store_durably(path: &Path, bytes: &[u8]) -> Cmd<()> { + write_bytes_atomically(path, bytes, true).await +} + +// Quarantines an unparseable store instead of overwriting it. Losing a store to a +// bug is bad; silently replacing it with a default and destroying the evidence is +// worse, so the bad bytes are kept for manual recovery. +pub(crate) async fn quarantine_unreadable_store(path: &Path) { + if !tokio::fs::try_exists(path).await.unwrap_or(false) { + return; + } + let mut name = path.file_name().unwrap_or_default().to_os_string(); + name.push(format!(".corrupt-{}", now().replace(':', "-"))); + let target = path.with_file_name(name); + match tokio::fs::rename(path, &target).await { + Ok(()) => diag_error!("kept unreadable store at {} for recovery", target.display()), + Err(error) => diag_error!("could not quarantine {}: {error}", path.display()), + } +} + +// Load order: primary store, then `.bak`, then a fresh default. A parse failure on +// the primary is treated as corruption rather than as an error, so one bad file +// cannot make the app permanently unopenable. +pub(crate) async fn read_json_or_default(path: &Path) -> Cmd +where + T: DeserializeOwned + Default + Serialize, +{ + if let Ok(raw) = tokio::fs::read_to_string(path).await { + match serde_json::from_str::(&raw) { + Ok(value) => return Ok(value), + Err(error) => diag_warn!("{} is unreadable ({error}); trying backup", path.display()), + } + } + + let backup = backup_path(path); + if let Ok(raw) = tokio::fs::read_to_string(&backup).await { + if let Ok(value) = serde_json::from_str::(&raw) { + diag_info!("recovered {} from backup", path.display()); + quarantine_unreadable_store(path).await; + // Restore without rotating: the backup we just read is the only good + // copy left, and rotating here would overwrite it with the bad file. + write_bytes_atomically(path, raw.as_bytes(), false).await?; + return Ok(value); + } + diag_error!("backup {} is also unreadable", backup.display()); + } + + quarantine_unreadable_store(path).await; + let data = T::default(); + let raw = serde_json::to_string_pretty(&data).map_err(|error| error.to_string())?; + write_bytes_atomically(path, format!("{raw}\n").as_bytes(), false).await?; + Ok(data) +} + +pub(crate) async fn save_json(path: &Path, data: &T) -> Cmd<()> { + let raw = serde_json::to_string_pretty(data).map_err(|error| error.to_string())?; + write_store_durably(path, format!("{raw}\n").as_bytes()).await +} + +pub(crate) async fn get_collection(path: &Path, collection_id: &str) -> Cmd { + load_library(path) + .await? + .collections + .into_iter() + .find(|collection| collection.id == collection_id) + .ok_or_else(|| "Collection not found.".to_string()) +} diff --git a/src-tauri/src/system.rs b/src-tauri/src/system.rs new file mode 100644 index 0000000..a454db6 --- /dev/null +++ b/src-tauri/src/system.rs @@ -0,0 +1,391 @@ +//! System status, settings, session restore, downloads, window geometry, and +//! conversation threads — the state that has to survive a quit. + +use super::*; + +pub(crate) async fn system_status(state: &State<'_, Backend>) -> Cmd { + let settings = load_settings(&state.paths.settings_path).await?; + let library = load_library(&state.paths.library_path).await?; + let catalog = model_catalog(&state.paths, &settings.local_model); + Ok(SystemStatus { + runtime_ready: catalog.chat_model.is_some() || catalog.embedding_model.is_some(), + runtime_name: LOCAL_RUNTIME_NAME.to_string(), + embedding_model: catalog + .embedding_model + .as_ref() + .map(|path| path_to_model_value(path)), + chat_model: catalog + .chat_model + .as_ref() + .map(|path| path_to_model_value(path)), + available_models: catalog + .models + .iter() + .map(|path| path_to_model_value(path)) + .collect(), + chat_models: catalog + .models + .iter() + .filter(|path| is_chat_model(path)) + .map(|path| path_to_model_value(path)) + .collect(), + embedding_models: catalog + .models + .iter() + .filter(|path| is_embedding_model(path)) + .map(|path| path_to_model_value(path)) + .collect(), + model_dir: state.paths.models_path.display().to_string(), + db_path: state.paths.db_path.display().to_string(), + library_path: state.paths.library_path.display().to_string(), + collections: library.collections, + error: catalog.error, + }) +} + +pub(crate) async fn load_library(path: &Path) -> Cmd { + read_json_or_default(path).await +} + +pub(crate) async fn load_settings(path: &Path) -> Cmd { + read_json_or_default(path).await +} + +pub(crate) async fn persist_update_last_checked_at(paths: &DataPaths, checked_at: &str) -> Cmd<()> { + let mut settings = load_settings(&paths.settings_path).await?; + settings.updates.last_checked_at = Some(checked_at.to_string()); + save_json(&paths.settings_path, &settings).await +} + +pub(crate) fn release_version_from_tag(tag: &str) -> String { + tag.trim() + .trim_start_matches('v') + .trim_start_matches('V') + .to_string() +} + +pub(crate) fn version_is_newer(candidate: &str, current: &str) -> bool { + let candidate_parts = version_parts(candidate); + let current_parts = version_parts(current); + let max_len = candidate_parts.len().max(current_parts.len()).max(3); + for index in 0..max_len { + let candidate_value = candidate_parts.get(index).copied().unwrap_or_default(); + let current_value = current_parts.get(index).copied().unwrap_or_default(); + if candidate_value > current_value { + return true; + } + if candidate_value < current_value { + return false; + } + } + false +} + +pub(crate) fn version_parts(version: &str) -> Vec { + version + .trim() + .trim_start_matches('v') + .trim_start_matches('V') + .split(|character: char| !character.is_ascii_digit()) + .filter(|part| !part.is_empty()) + .filter_map(|part| part.parse::().ok()) + .collect() +} + +pub(crate) async fn load_icebergs(path: &Path) -> Cmd { + read_json_or_default(path).await +} + +pub(crate) async fn load_conversations(path: &Path) -> Cmd { + read_json_or_default(path).await +} + +pub(crate) async fn load_session(path: &Path) -> Cmd { + read_json_or_default(path).await +} + +// Snapshots the open tabs. Called after any tab mutation rather than at exit, because +// force_exit() hard-kills the process on quit and never gives a shutdown hook a turn. +pub(crate) async fn persist_session_tabs(state: &State<'_, Backend>) -> Cmd<()> { + let (tabs, active_tab_id) = { + let guard = lock_tabs(state)?; + let tabs = guard + .tabs + .iter() + // A tab parked on the internal start page has nothing to reopen. + .filter(|tab| tab.url != START_PAGE_URL && !tab.url.starts_with("aether://")) + .map(|tab| SessionTab { + id: tab.id.clone(), + url: tab.url.clone(), + title: tab.title.clone(), + }) + .collect::>(); + (tabs, guard.active_tab_id.clone()) + }; + + let mut session = load_session(&state.paths.session_path).await?; + session.tabs = tabs; + session.active_tab_id = active_tab_id; + save_json(&state.paths.session_path, &session).await +} + +// Fire-and-forget wrapper for the sync command paths. A failed session write must +// never make a tab action fail. +pub(crate) fn schedule_session_save(app: &AppHandle) { + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let state = app.state::(); + if let Err(error) = persist_session_tabs(&state).await { + diag_warn!("could not save session: {error}"); + } + }); +} + +pub(crate) async fn persist_session_window(paths: &DataPaths, window: SessionWindow) -> Cmd<()> { + let mut session = load_session(&paths.session_path).await?; + session.window = Some(window); + save_json(&paths.session_path, &session).await +} + +#[cfg(desktop)] +pub(crate) fn file_name_of(path: &Path) -> String { + path.file_name() + .map(|name| name.to_string_lossy().to_string()) + .unwrap_or_else(|| "download".to_string()) +} + +// Derives a safe filename from a URL. Path separators and control characters are +// stripped so a crafted URL cannot write outside the downloads directory. +#[cfg(desktop)] +pub(crate) fn file_name_from_url(url: &Url) -> String { + let raw = url + .path_segments() + .and_then(|mut segments| segments.rfind(|segment| !segment.is_empty())) + .unwrap_or("download"); + let decoded = percent_decode_download_name(raw); + let cleaned = decoded + .chars() + .filter(|character| { + !character.is_control() && !matches!(character, '/' | '\\' | ':' | '\0') + }) + .collect::(); + let trimmed = cleaned.trim().trim_start_matches('.').to_string(); + if trimmed.is_empty() { + "download".to_string() + } else { + trimmed.chars().take(180).collect() + } +} + +#[cfg(desktop)] +pub(crate) fn percent_decode_download_name(raw: &str) -> String { + let bytes = raw.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + if bytes[index] == b'%' && index + 2 < bytes.len() { + if let Ok(byte) = u8::from_str_radix(&raw[index + 1..index + 3], 16) { + out.push(byte); + index += 3; + continue; + } + } + out.push(bytes[index]); + index += 1; + } + String::from_utf8_lossy(&out).to_string() +} + +// Never overwrite an existing file: a second download of the same name becomes +// "name (2).ext" the way a browser does. +#[cfg(desktop)] +pub(crate) fn resolve_download_destination(app: &AppHandle, url: &Url) -> Option { + let dir = app.path().download_dir().ok().or_else(|| { + app.path() + .home_dir() + .ok() + .map(|home| home.join("Downloads")) + })?; + if fs::create_dir_all(&dir).is_err() { + return None; + } + + let name = file_name_from_url(url); + let candidate = dir.join(&name); + if !candidate.exists() { + return Some(candidate); + } + + let path = Path::new(&name); + let stem = path + .file_stem() + .map(|stem| stem.to_string_lossy().to_string()) + .unwrap_or_else(|| "download".to_string()); + let extension = path + .extension() + .map(|extension| format!(".{}", extension.to_string_lossy())) + .unwrap_or_default(); + for index in 2..1000 { + let candidate = dir.join(format!("{stem} ({index}){extension}")); + if !candidate.exists() { + return Some(candidate); + } + } + None +} + +#[cfg(desktop)] +pub(crate) fn emit_download_event( + app: &AppHandle, + status: &str, + filename: &str, + path: Option<&Path>, + url: &str, +) { + let _ = app.emit( + AETHER_DOWNLOAD_EVENT, + DownloadProgress { + status: status.to_string(), + filename: filename.to_string(), + path: path.map(|path| path.display().to_string()), + url: url.to_string(), + }, + ); +} + +#[cfg(desktop)] +pub(crate) fn restore_session_tabs(state: &State, session: &SessionData) { + if session.tabs.is_empty() { + return; + } + let Ok(mut tabs) = state.tabs.lock() else { + return; + }; + let restored = session + .tabs + .iter() + .map(|tab| { + let mut managed = ManagedTab::new("browser", &tab.url); + // Keep the stored id so the saved active-tab id still resolves. + managed.id = tab.id.clone(); + if !tab.title.is_empty() { + managed.title = tab.title.clone(); + } + managed + }) + .collect::>(); + + let active = if restored.iter().any(|tab| tab.id == session.active_tab_id) { + session.active_tab_id.clone() + } else { + restored[0].id.clone() + }; + + tabs.active_tab_id = active; + tabs.tabs = restored; + // The dashboard is still the landing surface; restored tabs wait behind it. + tabs.dashboard_open = true; +} + +#[cfg(desktop)] +pub(crate) fn current_window_geometry(window: &Window) -> Option { + let scale = window.scale_factor().ok()?; + let size = window.inner_size().ok()?.to_logical::(scale); + let position = window.outer_position().ok()?.to_logical::(scale); + Some(SessionWindow { + width: size.width, + height: size.height, + x: position.x, + y: position.y, + }) +} + +#[cfg(desktop)] +pub(crate) fn apply_session_window(window: &Window, geometry: SessionWindow) { + // Guard against a stored geometry that would open the window off-screen or too + // small to use (a display was unplugged, or the config was hand-edited). + if geometry.width < 480.0 || geometry.height < 360.0 { + return; + } + let _ = window.set_size(Size::Logical(LogicalSize::new( + geometry.width, + geometry.height, + ))); + if geometry.x > -20_000.0 && geometry.y > -20_000.0 { + let _ = window.set_position(Position::Logical(LogicalPosition::new( + geometry.x, geometry.y, + ))); + } +} + +#[cfg(desktop)] +pub(crate) fn save_window_geometry_now(app: &AppHandle) { + let Some(window) = app.get_window("main") else { + return; + }; + let Some(geometry) = current_window_geometry(&window) else { + return; + }; + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let state = app.state::(); + if let Err(error) = persist_session_window(&state.paths, geometry).await { + diag_warn!("could not save window geometry: {error}"); + } + }); +} + +// Resize and move fire continuously while dragging, so throttle the writes. The +// CloseRequested handler catches whatever the throttle skipped. +#[cfg(desktop)] +pub(crate) fn schedule_window_geometry_save(app: &AppHandle) { + let state = app.state::(); + { + let Ok(mut last) = state.window_geometry_saved_at.lock() else { + return; + }; + if let Some(previous) = *last { + if previous.elapsed() < Duration::from_millis(500) { + return; + } + } + *last = Some(Instant::now()); + } + save_window_geometry_now(app); +} + +pub(crate) fn conversation_thread_key(collection_id: Option<&str>) -> String { + collection_id + .filter(|id| !id.is_empty()) + .unwrap_or(CURRENT_PAGE_THREAD_KEY) + .to_string() +} + +pub(crate) async fn conversation_thread( + paths: &DataPaths, + collection_id: Option<&str>, +) -> Vec { + let key = conversation_thread_key(collection_id); + load_conversations(&paths.conversations_path) + .await + .unwrap_or_default() + .threads + .remove(&key) + .unwrap_or_default() +} + +pub(crate) async fn append_conversation_turn( + paths: &DataPaths, + collection_id: Option<&str>, + turn: ConversationTurn, +) -> Cmd<()> { + let key = conversation_thread_key(collection_id); + let mut data = load_conversations(&paths.conversations_path).await?; + let thread = data.threads.entry(key).or_default(); + thread.push(turn); + if thread.len() > MAX_THREAD_TURNS { + let overflow = thread.len() - MAX_THREAD_TURNS; + thread.drain(0..overflow); + } + save_json(&paths.conversations_path, &data).await +} diff --git a/src-tauri/src/trail.rs b/src-tauri/src/trail.rs new file mode 100644 index 0000000..aac25f5 --- /dev/null +++ b/src-tauri/src/trail.rs @@ -0,0 +1,217 @@ +//! Semantic trails: the ranked, reasoned list of captures related to a query. + +use super::*; + +pub(crate) fn semantic_trail_default_query(captured: &CapturedPage) -> String { + normalize_captured_text(&format!( + "{}\n\n{}", + captured.title, + semantic_trail_excerpt(&captured.text, 1600) + )) +} + +pub(crate) fn semantic_trail_items( + candidates: Vec, + limit: usize, +) -> Vec { + let mut items = Vec::::new(); + let mut indexes = HashMap::::new(); + + for candidate in candidates { + let key = normalize_capture_url_key(&candidate.chunk.url); + if let Some(index) = indexes.get(&key).copied() { + let item = &mut items[index]; + item.excerpt = merge_semantic_trail_excerpts(&item.excerpt, &candidate.chunk.text); + for reason in candidate.reasons { + add_semantic_trail_reason(&mut item.reasons, reason); + } + continue; + } + + if items.len() >= limit { + continue; + } + + indexes.insert(key, items.len()); + items.push(SemanticTrailItem { + id: candidate.chunk.capture_id.clone(), + collection_id: candidate.chunk.collection_id.clone(), + collection_name: candidate.collection_name, + capture_id: candidate.chunk.capture_id, + app_id: candidate.chunk.app_id, + title: candidate.chunk.title, + url: candidate.chunk.url.clone(), + host: get_tab_host(&candidate.chunk.url), + captured_at: candidate.chunk.captured_at, + chunk_index: candidate.chunk.chunk_index, + excerpt: semantic_trail_excerpt(&candidate.chunk.text, 520), + score: candidate.score, + reasons: candidate.reasons, + }); + } + + items +} + +pub(crate) fn semantic_trail_score_breakdown( + distance: f64, + captured_at: &str, +) -> SemanticTrailScoreBreakdown { + // Relatedness is about meaning across the whole library, regardless of where a source + // came from. Semantic similarity dominates; recency is only a gentle tiebreaker. + let semantic = semantic_score_from_distance(distance); + let recency = semantic_trail_recency_score(captured_at); + let total = round_score((semantic * 0.92) + (recency * 0.08)); + + SemanticTrailScoreBreakdown { + total, + semantic, + recency, + } +} + +pub(crate) fn semantic_score_from_distance(distance: f64) -> f64 { + if !distance.is_finite() { + return 0.0; + } + round_score((1.0 - (distance / 1.2)).clamp(0.0, 1.0) * 100.0) +} + +pub(crate) fn semantic_trail_recency_score(captured_at: &str) -> f64 { + let Ok(parsed) = DateTime::parse_from_rfc3339(captured_at) else { + return 0.0; + }; + let days = Utc::now() + .signed_duration_since(parsed.with_timezone(&Utc)) + .num_days() + .max(0); + match days { + 0..=7 => 100.0, + 8..=30 => 80.0, + 31..=90 => 60.0, + 91..=180 => 40.0, + 181..=365 => 25.0, + _ => 10.0, + } +} + +pub(crate) fn semantic_trail_reasons( + score: &SemanticTrailScoreBreakdown, + same_collection: bool, +) -> Vec { + let mut reasons = Vec::new(); + if score.semantic >= 55.0 { + reasons.push(SemanticTrailReason::SemanticMatch); + } + if score.recency >= 80.0 { + reasons.push(SemanticTrailReason::RecentCapture); + } + if same_collection { + reasons.push(SemanticTrailReason::SameCollection); + } + if reasons.is_empty() { + reasons.push(SemanticTrailReason::SemanticMatch); + } + reasons +} + +pub(crate) fn semantic_trail_edges( + root: &SemanticTrailRoot, + items: &[SemanticTrailItem], +) -> Vec { + let mut edges = Vec::new(); + for item in items.iter().take(12) { + push_semantic_trail_edge( + &mut edges, + "root", + &item.id, + SemanticTrailEdgeKind::SemanticMatch, + item.score.total, + ); + if !root.host.is_empty() && root.host == item.host { + push_semantic_trail_edge( + &mut edges, + "root", + &item.id, + SemanticTrailEdgeKind::SameHost, + item.score.total, + ); + } + } + + for left_index in 0..items.len().min(8) { + for right_index in (left_index + 1)..items.len().min(8) { + let left = &items[left_index]; + let right = &items[right_index]; + let weight = left.score.total.min(right.score.total); + if left.collection_id == right.collection_id { + push_semantic_trail_edge( + &mut edges, + &left.id, + &right.id, + SemanticTrailEdgeKind::SameCollection, + weight, + ); + } else if !left.host.is_empty() && left.host == right.host { + push_semantic_trail_edge( + &mut edges, + &left.id, + &right.id, + SemanticTrailEdgeKind::SameHost, + weight, + ); + } + } + } + + edges +} + +pub(crate) fn push_semantic_trail_edge( + edges: &mut Vec, + from: &str, + to: &str, + kind: SemanticTrailEdgeKind, + weight: f64, +) { + if edges.len() >= 36 || from == to { + return; + } + edges.push(SemanticTrailEdge { + from: from.to_string(), + to: to.to_string(), + kind, + weight: round_score(weight), + }); +} + +pub(crate) fn add_semantic_trail_reason( + reasons: &mut Vec, + reason: SemanticTrailReason, +) { + if !reasons.contains(&reason) { + reasons.push(reason); + } +} + +pub(crate) fn merge_semantic_trail_excerpts(existing: &str, next: &str) -> String { + let next = semantic_trail_excerpt(next, 360); + if next.is_empty() || existing.contains(&next) { + return existing.to_string(); + } + semantic_trail_excerpt(&format!("{existing}\n\n[…]\n\n{next}"), 920) +} + +pub(crate) fn semantic_trail_excerpt(text: &str, limit: usize) -> String { + let normalized = normalize_captured_text(text); + if normalized.chars().count() <= limit { + return normalized; + } + let mut excerpt = normalized.chars().take(limit).collect::(); + excerpt.push('…'); + excerpt +} + +pub(crate) fn round_score(value: f64) -> f64 { + (value * 10.0).round() / 10.0 +} diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs new file mode 100644 index 0000000..cf744fa --- /dev/null +++ b/src-tauri/src/types.rs @@ -0,0 +1,1383 @@ +//! Serde types crossing the IPC boundary, plus the on-disk store shapes. +//! +//! Extracted verbatim from lib.rs. Field names and `serde` attributes here are the +//! contract with src/shared/aether.ts — renaming one silently breaks the renderer. + +use super::*; + +pub(crate) struct Backend { + pub(crate) paths: DataPaths, + pub(crate) tabs: Mutex, + #[cfg(desktop)] + pub(crate) webviews: Mutex, + // Where the renderer wants live web content placed, in CSS pixels, reported via + // aether_layout_set_web_content_bounds. Both shells use it; on desktop it takes + // precedence over the SIDEBAR_WIDTH/BROWSER_VIEW_TOP/PANEL_WIDTH constants. + pub(crate) web_content_bounds: Mutex, + pub(crate) client: Client, + pub(crate) native_runtime: Arc>, + pub(crate) vectors: tokio::sync::RwLock>, + pub(crate) generation_cancelled: Arc, + // Throttle for window geometry writes; resize/move fire continuously. + #[cfg(desktop)] + pub(crate) window_geometry_saved_at: Mutex>, + // Destination chosen at request time, keyed by URL. macOS omits the path in the + // Finished event, so without this the completion toast has nothing to reveal. + #[cfg(desktop)] + pub(crate) pending_downloads: Mutex>, +} + +#[cfg(desktop)] +#[derive(Default)] +pub(crate) struct NativeBrowserViews { + pub(crate) views: HashMap, +} + +// Where live web content belongs inside the window, in CSS px, as measured by the +// renderer. Both shells report the same rect: Android positions native WebViews with +// it, desktop positions its child webviews with it. Measuring beats hardcoding, +// because the chrome that defines these edges is owned by CSS. +#[derive(Clone, Copy, Default, PartialEq)] +pub(crate) struct WebContentBounds { + pub(crate) top: f64, + pub(crate) left: f64, + pub(crate) width: f64, + pub(crate) height: f64, +} + +#[derive(Default)] +pub(crate) struct NativeModelRuntime { + pub(crate) backend: Option, + pub(crate) chat: Option, + pub(crate) embedding: Option, +} + +pub(crate) struct LoadedNativeModel { + pub(crate) path: PathBuf, + pub(crate) model: LlamaModel, +} + +#[derive(Clone)] +pub(crate) struct EmbeddingProgress { + pub(crate) app: AppHandle, + pub(crate) message: String, +} + +impl EmbeddingProgress { + pub(crate) fn emit(&self, current: usize, total: usize) { + emit_capture_progress(&self.app, &self.message, Some(current), Some(total)); + } + + pub(crate) fn emit_message(&self, message: impl Into, current: usize, total: usize) { + emit_capture_progress(&self.app, message, Some(current), Some(total)); + } +} + +pub(crate) struct ChatPromptMessage { + pub(crate) role: &'static str, + pub(crate) content: String, +} + +pub(crate) struct RenderedChatPrompt { + pub(crate) prompt: String, + pub(crate) add_bos: AddBos, +} + +#[derive(Clone, Copy)] +pub(crate) enum NativeModelKind { + Chat, + Embedding, +} + +pub(crate) enum WebviewHistoryDirection { + Back, + Forward, +} + +pub(crate) struct ModelCatalog { + pub(crate) models: Vec, + pub(crate) chat_model: Option, + pub(crate) embedding_model: Option, + pub(crate) error: Option, +} + +#[derive(Clone)] +pub(crate) struct DataPaths { + pub(crate) db_path: PathBuf, + pub(crate) library_path: PathBuf, + pub(crate) settings_path: PathBuf, + pub(crate) icebergs_path: PathBuf, + pub(crate) conversations_path: PathBuf, + pub(crate) session_path: PathBuf, + pub(crate) air_exports_path: PathBuf, + pub(crate) chunks_path: PathBuf, + pub(crate) models_path: PathBuf, + // User-owned library snapshots (see aether_system_export_library). + pub(crate) exports_path: PathBuf, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DiagnosticsExportResult { + pub(crate) path: String, + pub(crate) filename: String, + pub(crate) byte_size: u64, + pub(crate) exported_at: String, +} + +#[derive(Clone)] +pub(crate) struct TabState { + pub(crate) tabs: Vec, + pub(crate) active_app_id: String, + pub(crate) active_tab_id: String, + pub(crate) dashboard_open: bool, + pub(crate) modal_overlay_open: bool, + pub(crate) panel_collapsed: bool, +} + +#[derive(Clone)] +pub(crate) struct ManagedTab { + pub(crate) id: String, + pub(crate) app_id: String, + pub(crate) title: String, + pub(crate) url: String, + pub(crate) is_loading: bool, + pub(crate) favicon: Option, + pub(crate) theme_color: Option, + pub(crate) history: Vec, + pub(crate) history_index: usize, + // On Android the tab's real history lives in its native WebView, whose + // canGoBack/canGoForward are reported via aether_tabs_report_native_event. + // They extend (OR with) the Rust-side history, which still tracks entries + // the WebView never saw — most notably the aether://start page. + pub(crate) native_can_go_back: Option, + pub(crate) native_can_go_forward: Option, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AppSummary { + pub(crate) id: String, + pub(crate) name: String, + pub(crate) category: String, + pub(crate) home_url: String, + pub(crate) current_url: String, + pub(crate) title: String, + pub(crate) is_active: bool, + pub(crate) is_loading: bool, + pub(crate) can_go_back: bool, + pub(crate) can_go_forward: bool, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct BrowserTabSummary { + pub(crate) id: String, + pub(crate) app_id: String, + pub(crate) title: String, + pub(crate) url: String, + pub(crate) host: String, + pub(crate) is_active: bool, + pub(crate) is_loading: bool, + pub(crate) can_go_back: bool, + pub(crate) can_go_forward: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) favicon: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) theme_color: Option, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AetherState { + pub(crate) apps: Vec, + pub(crate) tabs: Vec, + pub(crate) active_app_id: String, + pub(crate) active_tab_id: String, + pub(crate) dashboard_open: bool, + pub(crate) panel_collapsed: bool, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct HubShortcutSummary { + pub(crate) id: String, + pub(crate) title: String, + pub(crate) url: String, + pub(crate) host: String, + pub(crate) created_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) favicon: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) theme_color: Option, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct BrowserSettings { + pub(crate) default_search_engine: String, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UpdateSettings { + #[serde(default = "default_update_auto_check")] + pub(crate) auto_check: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) last_checked_at: Option, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AppSettings { + pub(crate) browser: BrowserSettings, + pub(crate) developer_mode: bool, + pub(crate) updates: UpdateSettings, + pub(crate) appearance: Appearance, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CollectionSummary { + pub(crate) id: String, + pub(crate) name: String, + pub(crate) description: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) icon: Option, + pub(crate) created_at: String, + pub(crate) updated_at: String, + pub(crate) capture_count: usize, + pub(crate) chunk_count: usize, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CaptureMetadata { + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) note: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) summary: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) tags: Option>, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CaptureSummary { + pub(crate) id: String, + pub(crate) collection_id: String, + pub(crate) title: String, + pub(crate) url: String, + pub(crate) app_id: String, + pub(crate) captured_at: String, + pub(crate) chunk_count: usize, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) metadata: Option, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CaptureResult { + #[serde(flatten)] + pub(crate) capture: CaptureSummary, + pub(crate) collection_name: String, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CaptureProgress { + pub(crate) message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) current: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) total: Option, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SearchResult { + pub(crate) id: String, + pub(crate) collection_id: String, + pub(crate) capture_id: String, + pub(crate) app_id: String, + pub(crate) title: String, + pub(crate) url: String, + pub(crate) captured_at: String, + pub(crate) chunk_index: usize, + pub(crate) text: String, + pub(crate) score: f64, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SemanticTrailRoot { + pub(crate) title: String, + pub(crate) url: String, + pub(crate) host: String, + pub(crate) excerpt: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum SemanticTrailReason { + SemanticMatch, + RecentCapture, + SameCollection, +} + +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum SemanticTrailEdgeKind { + SemanticMatch, + SameHost, + SameCollection, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SemanticTrailScoreBreakdown { + pub(crate) total: f64, + pub(crate) semantic: f64, + pub(crate) recency: f64, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SemanticTrailItem { + pub(crate) id: String, + pub(crate) collection_id: String, + pub(crate) collection_name: String, + pub(crate) capture_id: String, + pub(crate) app_id: String, + pub(crate) title: String, + pub(crate) url: String, + pub(crate) host: String, + pub(crate) captured_at: String, + pub(crate) chunk_index: usize, + pub(crate) excerpt: String, + pub(crate) score: SemanticTrailScoreBreakdown, + pub(crate) reasons: Vec, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SemanticTrailEdge { + pub(crate) from: String, + pub(crate) to: String, + pub(crate) kind: SemanticTrailEdgeKind, + pub(crate) weight: f64, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CaptureHubSuggestion { + pub(crate) collection_id: String, + pub(crate) collection_name: String, + pub(crate) confidence: f64, + pub(crate) sample_title: String, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SemanticTrailResult { + pub(crate) query: String, + pub(crate) generated_at: String, + pub(crate) root: SemanticTrailRoot, + pub(crate) items: Vec, + pub(crate) edges: Vec, +} + +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum FlowGraphNodeKind { + Query, + Hub, + Source, +} + +#[derive(Clone, Copy, Serialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum FlowGraphEdgeKind { + Contains, + Semantic, + QueryMatch, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct FlowGraphNode { + pub(crate) id: String, + pub(crate) kind: FlowGraphNodeKind, + pub(crate) title: String, + pub(crate) subtitle: String, + pub(crate) weight: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) collection_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) collection_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) capture_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) host: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) captured_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) excerpt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) score: Option, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct FlowGraphEdge { + pub(crate) id: String, + pub(crate) from: String, + pub(crate) to: String, + pub(crate) kind: FlowGraphEdgeKind, + pub(crate) weight: f64, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct FlowGraphResult { + pub(crate) query: String, + pub(crate) generated_at: String, + pub(crate) nodes: Vec, + pub(crate) edges: Vec, + pub(crate) hub_count: usize, + pub(crate) source_count: usize, + pub(crate) omitted_source_count: usize, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ChatResult { + pub(crate) answer: String, + pub(crate) model: String, + pub(crate) citations: Vec, + pub(crate) metrics: ChatMetrics, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ChatMetrics { + pub(crate) generated_tokens: usize, + pub(crate) tokens_per_second: f64, + pub(crate) elapsed_seconds: f64, + pub(crate) chunks: usize, +} + +pub(crate) struct ChatCompletion { + pub(crate) text: String, + pub(crate) generated_tokens: usize, +} + +#[derive(Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +#[derive(Default)] +pub(crate) enum AirLensKind { + #[default] + Topic, + Flow, + Hub, + Answer, + Iceberg, +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AirDossierInput { + pub(crate) lens: String, + pub(crate) lens_kind: Option, + pub(crate) collection_id: Option, + pub(crate) capture_id: Option, + pub(crate) saved_iceberg_id: Option, + pub(crate) answer: Option, + pub(crate) limit: Option, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AirDossierSource { + pub(crate) id: String, + pub(crate) title: String, + pub(crate) excerpt: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) collection_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) host: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) captured_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) score: Option, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AirPreparedDossier { + pub(crate) title: String, + pub(crate) lens: String, + pub(crate) lens_kind: AirLensKind, + pub(crate) generated_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) model: Option, + pub(crate) output_dir: String, + pub(crate) markdown_preview: String, + pub(crate) sources: Vec, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AirRenderResult { + pub(crate) path: String, + pub(crate) filename: String, + pub(crate) title: String, + pub(crate) source_count: usize, + pub(crate) rendered_at: String, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AirRecentFile { + pub(crate) path: String, + pub(crate) filename: String, + pub(crate) title: String, + pub(crate) lens: String, + pub(crate) source_count: usize, + pub(crate) rendered_at: String, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ChatStreamPayload { + pub(crate) request_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) delta: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) citations: Option>, +} + +#[derive(Clone)] +pub(crate) struct ChatStreamEmitter { + pub(crate) app: AppHandle, + pub(crate) request_id: String, +} + +impl ChatStreamEmitter { + pub(crate) fn emit(&self, payload: ChatStreamPayload) { + let _ = self.app.emit(AETHER_CHAT_STREAM_EVENT, payload); + } + + pub(crate) fn status(&self, status: &str) { + self.emit(ChatStreamPayload { + request_id: self.request_id.clone(), + status: Some(status.to_string()), + delta: None, + citations: None, + }); + } + + pub(crate) fn citations(&self, citations: &[SearchResult]) { + self.emit(ChatStreamPayload { + request_id: self.request_id.clone(), + status: Some("Generating answer".to_string()), + delta: None, + citations: Some(citations.to_vec()), + }); + } + + pub(crate) fn delta(&self, delta: &str) { + self.emit(ChatStreamPayload { + request_id: self.request_id.clone(), + status: None, + delta: Some(delta.to_string()), + citations: None, + }); + } +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct IcebergItem { + pub(crate) id: String, + pub(crate) name: String, + pub(crate) description: String, + pub(crate) level: u8, + pub(crate) x: f64, + pub(crate) y: f64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) depth_score: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) familiarity: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) specificity: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) jargon_density: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) prerequisite_depth: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) obscurity: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) reason: Option, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct IcebergResult { + pub(crate) keyword: String, + pub(crate) model: String, + pub(crate) items: Vec, + pub(crate) generated_at: String, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SavedIcebergSummary { + pub(crate) id: String, + pub(crate) title: String, + pub(crate) keyword: String, + pub(crate) model: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) icon: Option, + pub(crate) generated_at: String, + pub(crate) saved_at: String, + pub(crate) updated_at: String, + pub(crate) item_count: usize, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SavedIceberg { + #[serde(flatten)] + pub(crate) iceberg: IcebergResult, + pub(crate) id: String, + pub(crate) title: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) icon: Option, + pub(crate) saved_at: String, + pub(crate) updated_at: String, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SystemStatus { + pub(crate) runtime_ready: bool, + pub(crate) runtime_name: String, + pub(crate) embedding_model: Option, + pub(crate) chat_model: Option, + pub(crate) available_models: Vec, + pub(crate) chat_models: Vec, + pub(crate) embedding_models: Vec, + pub(crate) model_dir: String, + pub(crate) db_path: String, + pub(crate) library_path: String, + pub(crate) collections: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) error: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CreateTabInput { + pub(crate) url: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CreateShortcutInput { + pub(crate) title: String, + pub(crate) url: String, + pub(crate) favicon: Option, + pub(crate) theme_color: Option, +} + +#[cfg(desktop)] +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PageMetadataSnapshot { + pub(crate) theme_color: Option, + pub(crate) favicon: Option, +} + +#[cfg(desktop)] +#[derive(Deserialize)] +pub(crate) struct FindMatchSnapshot { + pub(crate) current: usize, + pub(crate) total: usize, +} + +// Event payload forwarded by the renderer from the Kotlin TabsPlugin +// (window.__AETHER_TAB_EVENT__): per-tab navigation, title, and find updates. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct NativeTabEventInput { + pub(crate) tab_id: String, + pub(crate) kind: String, + #[serde(default)] + pub(crate) url: Option, + #[serde(default)] + pub(crate) title: Option, + #[serde(default)] + pub(crate) is_loading: Option, + #[serde(default)] + pub(crate) can_go_back: Option, + #[serde(default)] + pub(crate) can_go_forward: Option, + #[serde(default)] + pub(crate) current: Option, + #[serde(default)] + pub(crate) total: Option, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct FindResultPayload { + pub(crate) tab_id: String, + pub(crate) current: usize, + pub(crate) total: usize, +} + +#[derive(Deserialize)] +pub(crate) struct CreateCollectionInput { + pub(crate) name: String, + pub(crate) description: Option, + pub(crate) icon: Option, +} + +#[derive(Deserialize)] +pub(crate) struct UpdateCollectionInput { + pub(crate) id: String, + pub(crate) name: Option, + pub(crate) description: Option, + pub(crate) icon: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CaptureCurrentPageInput { + pub(crate) collection_id: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SearchLibraryInput { + pub(crate) query: String, + // None searches every hub. + #[serde(default)] + pub(crate) collection_id: Option, + #[serde(default)] + pub(crate) limit: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LibrarySearchHit { + pub(crate) capture_id: String, + pub(crate) collection_id: String, + pub(crate) collection_name: String, + pub(crate) title: String, + pub(crate) url: String, + pub(crate) host: String, + pub(crate) captured_at: String, + pub(crate) excerpt: String, + // 0-100 display score, not raw cosine distance. + pub(crate) score: f64, + pub(crate) chunk_matches: usize, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LibrarySearchResult { + pub(crate) query: String, + pub(crate) hits: Vec, + // "semantic" when an embedding model ranked the results, "literal" when it fell + // back to substring matching so the UI can say which happened. + pub(crate) mode: String, + pub(crate) searched_chunks: usize, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CaptureUrlInput { + pub(crate) collection_id: String, + pub(crate) url: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct CaptureUrlsInput { + pub(crate) collection_id: String, + pub(crate) urls: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct BulkCaptureFailure { + pub(crate) url: String, + pub(crate) reason: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct BulkCaptureResult { + pub(crate) captured: Vec, + pub(crate) collection_name: String, + pub(crate) failures: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct MoveCaptureInput { + pub(crate) capture_id: String, + pub(crate) collection_id: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SearchCollectionInput { + pub(crate) collection_id: String, + pub(crate) query: String, + pub(crate) limit: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SemanticTrailInput { + pub(crate) query: Option, + pub(crate) limit: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct FlowGraphInput { + pub(crate) query: Option, + pub(crate) source_limit: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AskChatInput { + pub(crate) collection_id: Option, + pub(crate) prompt: String, + pub(crate) include_current_page: Option, + pub(crate) request_id: Option, +} + +#[derive(Deserialize)] +pub(crate) struct GenerateIcebergInput { + pub(crate) keyword: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SaveIcebergInput { + pub(crate) title: String, + pub(crate) keyword: String, + pub(crate) model: String, + pub(crate) icon: Option, + pub(crate) generated_at: String, + pub(crate) items: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UpdateSettingsInput { + pub(crate) browser: Option, + pub(crate) developer_mode: Option, + pub(crate) updates: Option, + pub(crate) appearance: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PartialBrowserSettings { + pub(crate) default_search_engine: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PartialUpdateSettings { + pub(crate) auto_check: Option, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DownloadProgress { + // "started" | "finished" | "failed" + pub(crate) status: String, + pub(crate) filename: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) path: Option, + pub(crate) url: String, +} + +// Restored on launch so quitting no longer discards every open tab. Only what is +// needed to reopen is stored: per-tab history stays in the webview. +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SessionTab { + pub(crate) id: String, + pub(crate) url: String, + #[serde(default)] + pub(crate) title: String, +} + +#[derive(Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SessionWindow { + pub(crate) width: f64, + pub(crate) height: f64, + pub(crate) x: f64, + pub(crate) y: f64, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SessionData { + pub(crate) version: u8, + #[serde(default)] + pub(crate) tabs: Vec, + #[serde(default)] + pub(crate) active_tab_id: String, + #[serde(default)] + pub(crate) window: Option, +} + +impl Default for SessionData { + fn default() -> Self { + Self { + version: 1, + tabs: Vec::new(), + active_tab_id: String::new(), + window: None, + } + } +} + +// One completed exchange. Stored per thread so a research session survives quitting +// the app, which is the whole point of keeping answers at all. +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ConversationTurn { + pub(crate) id: String, + pub(crate) prompt: String, + pub(crate) answer: String, + pub(crate) model: String, + pub(crate) asked_at: String, + pub(crate) citations: Vec, + pub(crate) metrics: ChatMetrics, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ConversationData { + pub(crate) version: u8, + // Keyed by hub id, or CURRENT_PAGE_THREAD_KEY for page-only asks. + #[serde(default)] + pub(crate) threads: HashMap>, +} + +impl Default for ConversationData { + fn default() -> Self { + Self { + version: 1, + threads: HashMap::new(), + } + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LibraryExportResult { + pub(crate) path: String, + pub(crate) exported_at: String, + pub(crate) files: Vec, + pub(crate) capture_count: usize, + pub(crate) chunk_count: usize, + pub(crate) byte_size: u64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LibraryIndexStatus { + pub(crate) dim: usize, + pub(crate) embedded: usize, + pub(crate) pending_reembed: usize, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LibraryReindexResult { + pub(crate) embedded: usize, + // Chunks the re-index could not embed. Non-zero means the model rejected them, so + // the number is worth showing rather than reporting a clean success. + pub(crate) still_pending: usize, + pub(crate) dim: usize, + pub(crate) reindexed_at: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UpdateCheckResult { + pub(crate) current_version: String, + pub(crate) checked_at: String, + pub(crate) update_available: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) latest_version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) latest_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) release_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) release_notes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) published_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) error: Option, +} + +// The in-app install can legitimately end four ways, and only one of them is a +// failure. Collapsing "this build has no signing key", "your install method +// updates by hand", and "the endpoint has nothing for this platform" into one +// error string would leave the user with no idea which applies to them. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum UpdateInstallStatus { + /// Downloaded, verified, and written over the installed bundle. + Installed, + /// No updater signing key was compiled into this build. + Unconfigured, + /// This platform or install method cannot replace itself in place. + Unsupported, + /// The updater endpoint has no newer build for this target. + Unavailable, +} + +impl UpdateInstallStatus { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Installed => "installed", + Self::Unconfigured => "unconfigured", + Self::Unsupported => "unsupported", + Self::Unavailable => "unavailable", + } + } +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UpdateInstallResult { + pub(crate) status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) version: Option, + /// Only macOS and Linux hand control back to us; the Windows installer exits + /// the app itself, so the renderer never gets to act on this there. + pub(crate) needs_restart: bool, + pub(crate) message: String, +} + +impl UpdateInstallResult { + pub(crate) fn new(status: UpdateInstallStatus, message: impl Into) -> Self { + Self { + status: status.as_str().to_string(), + version: None, + needs_restart: status == UpdateInstallStatus::Installed, + message: message.into(), + } + } +} + +#[cfg(desktop)] +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UpdateInstallProgress { + pub(crate) downloaded_bytes: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) total_bytes: Option, + pub(crate) done: bool, +} + +#[derive(Deserialize)] +pub(crate) struct GithubRelease { + pub(crate) tag_name: String, + pub(crate) name: Option, + pub(crate) html_url: String, + pub(crate) body: Option, + pub(crate) published_at: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UpdateModelsInput { + pub(crate) embedding_model: Option, + pub(crate) chat_model: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DownloadModelsInput { + pub(crate) chat_models: Vec, + #[serde(default)] + pub(crate) hf_token: Option, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ModelDownloadProgress { + pub(crate) id: String, + pub(crate) label: String, + pub(crate) filename: String, + pub(crate) status: String, + pub(crate) downloaded_bytes: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) total_bytes: Option, + pub(crate) overall_downloaded_bytes: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) overall_total_bytes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) message: Option, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct StatusToastInput { + pub(crate) message: String, + pub(crate) tone: String, + pub(crate) duration_ms: Option, +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LibraryData { + pub(crate) version: u8, + pub(crate) collections: Vec, + pub(crate) captures: Vec, + pub(crate) shortcuts: Vec, + pub(crate) migrated_realm_tables: Vec, +} + +impl Default for LibraryData { + fn default() -> Self { + Self { + version: 1, + collections: Vec::new(), + captures: Vec::new(), + shortcuts: Vec::new(), + migrated_realm_tables: Vec::new(), + } + } +} + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct UserSettings { + #[serde(default = "default_settings_version")] + pub(crate) version: u8, + #[serde(default)] + pub(crate) browser: BrowserSettings, + #[serde(default)] + pub(crate) developer_mode: bool, + #[serde(default)] + pub(crate) updates: UpdateSettings, + #[serde(default)] + pub(crate) appearance: Appearance, + #[serde(default, alias = "ollama")] + pub(crate) local_model: LocalModelSettings, +} + +// Which theme the renderer stamps onto the root element. `System` follows the OS +// via prefers-color-scheme; the other two override it in both directions, so a +// user on a dark desktop can still choose the light theme. +#[derive(Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum Appearance { + #[default] + System, + Light, + Dark, +} + +#[derive(Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub(crate) struct LocalModelSettings { + pub(crate) embedding_model: Option, + pub(crate) chat_model: Option, +} + +impl Default for BrowserSettings { + fn default() -> Self { + Self { + default_search_engine: "google".to_string(), + } + } +} + +impl Default for UpdateSettings { + fn default() -> Self { + Self { + auto_check: default_update_auto_check(), + last_checked_at: None, + } + } +} + +impl Default for UserSettings { + fn default() -> Self { + Self { + version: default_settings_version(), + browser: BrowserSettings::default(), + developer_mode: false, + updates: UpdateSettings::default(), + appearance: Appearance::default(), + local_model: LocalModelSettings::default(), + } + } +} + +pub(crate) fn default_settings_version() -> u8 { + 1 +} + +pub(crate) fn default_update_auto_check() -> bool { + true +} + +#[derive(Serialize, Deserialize)] +pub(crate) struct IcebergData { + pub(crate) version: u8, + pub(crate) icebergs: Vec, +} + +impl Default for IcebergData { + fn default() -> Self { + Self { + version: 1, + icebergs: Vec::new(), + } + } +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct ChunkRecord { + pub(crate) id: String, + // Vectors live in the binary sidecar, not in this JSON. Serializing 1024 f32s as + // decimal text cost ~12 KB per chunk and forced a full rewrite of every vector on + // every capture; `vector_slot` is the fixed-stride index into `chunks.vec`. + #[serde(skip)] + pub(crate) vector: Vec, + #[serde(default)] + pub(crate) vector_slot: u64, + // Set when the chunk's text is retained but its vector is not usable with the + // store's current width — typically because the embedding model changed. Such a + // chunk holds no sidecar slot and is invisible to semantic search until a + // re-index re-embeds it. Keeping the text is the whole point: re-embedding is + // local compute, whereas dropping the record would force a re-fetch of the page. + #[serde(default, skip_serializing_if = "is_false")] + pub(crate) needs_reembed: bool, + pub(crate) text: String, + pub(crate) collection_id: String, + pub(crate) capture_id: String, + pub(crate) title: String, + pub(crate) url: String, + pub(crate) app_id: String, + pub(crate) captured_at: String, + pub(crate) chunk_index: usize, +} + +pub(crate) fn is_false(value: &bool) -> bool { + !*value +} + +pub(crate) const VECTOR_STORE_VERSION: u8 = 2; +// Reclaim dead slots only once they dominate the file, so routine deletes stay O(1) +// instead of triggering a full rewrite each time. +pub(crate) const VECTOR_COMPACTION_MIN_SLOTS: u64 = 512; +pub(crate) const VECTOR_COMPACTION_DEAD_RATIO: f64 = 0.5; +// Re-index batch size. Small enough that progress moves visibly on a large library +// and peak memory stays bounded; large enough to keep the model's batching useful. +pub(crate) const REINDEX_BATCH_SIZE: usize = 64; + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct VectorStoreData { + pub(crate) version: u8, + // Embedding width. Fixes the sidecar stride, so the store holds exactly one width + // at a time. A fresh store learns it from the first vector; a migrated store takes + // the width the most chunks already use; a re-index resets it to the loaded model's. + #[serde(default)] + pub(crate) dim: usize, + // Monotonic slot allocator. Counts every slot ever handed out, including slots + // whose chunk was later deleted, so appends always land past live data. + #[serde(default)] + pub(crate) next_slot: u64, + pub(crate) chunks: Vec, +} + +impl Default for VectorStoreData { + fn default() -> Self { + Self { + version: VECTOR_STORE_VERSION, + dim: 0, + next_slot: 0, + chunks: Vec::new(), + } + } +} + +impl VectorStoreData { + // Single place that hands out vector slots, so no caller can append a chunk whose + // slot does not match its position in the sidecar. + pub(crate) fn push_chunks(&mut self, records: impl IntoIterator) { + for mut record in records { + if self.dim == 0 && !record.vector.is_empty() { + self.dim = record.vector.len(); + } + // A chunk whose width does not match the store is parked, not discarded. + // Dropping it would throw away the text as well, turning a re-embeddable + // problem into one that needs the page fetched again. + if record.vector.is_empty() || record.vector.len() != self.dim { + if !record.vector.is_empty() { + diag_info!( + "chunk {} has {} dims (store is {}); parked for re-indexing", + record.id, + record.vector.len(), + self.dim + ); + } + record.vector.clear(); + record.vector_slot = 0; + record.needs_reembed = true; + self.chunks.push(record); + continue; + } + record.needs_reembed = false; + record.vector_slot = self.next_slot; + self.next_slot += 1; + self.chunks.push(record); + } + } + + // Chunks holding a sidecar slot. Parked chunks are excluded, so this — not + // `chunks.len()` — is what the slot accounting must be measured against. + pub(crate) fn embedded_count(&self) -> u64 { + self.chunks + .iter() + .filter(|chunk| !chunk.needs_reembed) + .count() as u64 + } + + pub(crate) fn pending_reembed_count(&self) -> usize { + self.chunks + .iter() + .filter(|chunk| chunk.needs_reembed) + .count() + } +} + +// Vectors sit next to the metadata as `chunks.vec`. diff --git a/src-tauri/src/util.rs b/src-tauri/src/util.rs new file mode 100644 index 0000000..5cc3f5c --- /dev/null +++ b/src-tauri/src/util.rs @@ -0,0 +1,240 @@ +//! Small pure helpers: string normalisation, URL tidying, slugs, ids, clocks. + +use super::*; + +pub(crate) fn reorder(items: Vec, ids: &[String], id_of: F) -> Vec +where + T: Clone, + F: Fn(&T) -> &String, +{ + let requested = ids.iter().filter(|id| !id.is_empty()).collect::>(); + let requested_set = requested + .iter() + .map(|id| (*id).clone()) + .collect::>(); + let by_id = items + .iter() + .map(|item| (id_of(item).clone(), item.clone())) + .collect::>(); + let mut ordered = requested + .into_iter() + .filter_map(|id| by_id.get(id).cloned()) + .collect::>(); + ordered.extend( + items + .into_iter() + .filter(|item| !requested_set.contains(id_of(item))), + ); + ordered +} + +pub(crate) fn normalize_captured_text(text: &str) -> String { + text.replace('\r', "") + .split('\n') + .map(str::trim) + .collect::>() + .join("\n") + .split_whitespace() + .collect::>() + .join(" ") + .trim() + .to_string() +} + +pub(crate) fn normalize_url(raw_url: &str, search_engine: &str) -> String { + let trimmed = raw_url.trim(); + if trimmed.is_empty() { + return "https://www.google.com".to_string(); + } + if Url::parse(trimmed).is_ok() { + return trimmed.to_string(); + } + if trimmed.contains(char::is_whitespace) || !trimmed.contains(['.', ':']) { + return format!( + "{}{}", + search_engine_prefix(search_engine), + urlencoding(trimmed) + ); + } + if trimmed.starts_with("localhost") + || trimmed.starts_with("127.0.0.1") + || trimmed.starts_with("[::1]") + { + return format!("http://{trimmed}"); + } + format!("https://{trimmed}") +} + +pub(crate) fn search_engine_prefix(id: &str) -> &'static str { + match id { + "bing" => "https://www.bing.com/search?q=", + "yahoo" => "https://search.yahoo.com/search?p=", + "ecosia" => "https://www.ecosia.org/search?q=", + "duckduckgo" => "https://duckduckgo.com/?q=", + _ => "https://www.google.com/search?q=", + } +} + +pub(crate) fn normalize_search_engine_id(value: &str) -> String { + match value { + "google" | "bing" | "yahoo" | "ecosia" | "duckduckgo" => value.to_string(), + _ => "google".to_string(), + } +} + +pub(crate) fn normalize_iceberg_icon(value: Option) -> Option { + let allowed = [ + "atom", + "book", + "brain", + "briefcase", + "code", + "cpu", + "dna", + "film", + "flask", + "gamepad", + "globe", + "heart", + "landmark", + "microscope", + "music", + "palette", + "shield", + "snowflake", + "sprout", + "telescope", + ]; + value + .map(|icon| icon.trim().to_lowercase()) + .filter(|icon| allowed.contains(&icon.as_str())) +} + +pub(crate) fn normalize_theme_color(color: &str) -> Option { + let value = color.trim().chars().take(64).collect::(); + if value.is_empty() { + return None; + } + + if let Some(hex) = value.strip_prefix('#') { + if (3..=8).contains(&hex.len()) + && hex.chars().all(|character| character.is_ascii_hexdigit()) + { + return Some(value); + } + } + + let lower = value.to_ascii_lowercase(); + let supported_function = lower.starts_with("rgb(") + || lower.starts_with("rgba(") + || lower.starts_with("hsl(") + || lower.starts_with("hsla("); + if supported_function && value.ends_with(')') { + return Some(value); + } + + None +} + +pub(crate) fn title_from_url(url: &str) -> String { + let host = get_tab_host(url); + if host.is_empty() { + "New tab".to_string() + } else { + host + } +} + +pub(crate) fn favicon_for_url(url: &str) -> Option { + let parsed = Url::parse(url).ok()?; + Some(format!( + "{}://{}/favicon.ico", + parsed.scheme(), + parsed.host_str()? + )) +} + +pub(crate) fn get_tab_host(url: &str) -> String { + Url::parse(url) + .ok() + .and_then(|url| { + url.host_str() + .map(|host| host.trim_start_matches("www.").to_string()) + }) + .unwrap_or_default() +} + +pub(crate) fn normalize_citation_key(url: &str) -> String { + match Url::parse(url) { + Ok(mut parsed) => { + parsed.set_fragment(None); + parsed.to_string() + } + Err(_) => url.to_string(), + } +} + +pub(crate) fn normalize_capture_url_key(url: &str) -> String { + match Url::parse(url) { + Ok(mut parsed) => { + parsed.set_fragment(None); + if parsed.path() == "/" { + parsed.set_path(""); + } + parsed.to_string().trim_end_matches('/').to_string() + } + Err(_) => url.trim().trim_end_matches('/').to_string(), + } +} + +pub(crate) fn unique_slug(name: &str, existing: &[String]) -> String { + let base = slugify(name); + let mut candidate = base.clone(); + let mut suffix = 2; + while existing.contains(&candidate) { + candidate = format!("{base}-{suffix}"); + suffix += 1; + } + candidate +} + +pub(crate) fn slugify(value: &str) -> String { + let mut slug = String::new(); + let mut last_dash = false; + for char in value.trim().to_lowercase().chars() { + if char.is_ascii_alphanumeric() || char == '_' { + slug.push(char); + last_dash = false; + } else if !last_dash { + slug.push('-'); + last_dash = true; + } + } + let slug = slug.trim_matches('-').to_string(); + if slug.is_empty() { + "collection".to_string() + } else { + slug + } +} + +pub(crate) fn urlencoding(value: &str) -> String { + value + .bytes() + .flat_map(|byte| match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + vec![byte as char] + } + b' ' => vec!['+'], + _ => format!("%{byte:02X}").chars().collect(), + }) + .collect() +} + +pub(crate) fn uuid() -> String { + uuid::Uuid::new_v4().to_string() +} + +pub(crate) fn now() -> String { + Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true) +} diff --git a/src-tauri/src/vectors.rs b/src-tauri/src/vectors.rs new file mode 100644 index 0000000..a48fd25 --- /dev/null +++ b/src-tauri/src/vectors.rs @@ -0,0 +1,300 @@ +//! The binary vector sidecar (`chunks.vec`) beside the JSON chunk metadata. +//! +//! Write ordering is load-bearing: vector data is fsynced before the metadata that +//! references it, so a crash mid-save can leave an unreferenced slot but never a +//! chunk pointing at a slot that was never written. + +use super::*; + +pub(crate) async fn load_vectors(path: &Path) -> Cmd { + // A v1 store carries its vectors inline, so it must be detected before the v2 + // deserializer silently drops them (`vector` is #[serde(skip)] there). + if let Ok(raw) = tokio::fs::read_to_string(path).await { + let declared_version = serde_json::from_str::(&raw) + .ok() + .and_then(|value| value.get("version").and_then(serde_json::Value::as_u64)); + if declared_version.unwrap_or(1) < VECTOR_STORE_VERSION as u64 { + return migrate_legacy_vectors(path, &raw).await; + } + } + + let mut data: VectorStoreData = read_json_or_default(path).await?; + hydrate_vectors(path, &mut data).await?; + Ok(data) +} + +// Reads the sidecar and attaches each chunk's vector. Chunks whose slot is missing +// from the file are dropped rather than fatal: a partial sidecar should cost the +// affected sources, not the whole library. +pub(crate) async fn hydrate_vectors(path: &Path, data: &mut VectorStoreData) -> Cmd<()> { + if data.chunks.is_empty() { + return Ok(()); + } + if data.dim == 0 { + diag_warn!("vector store has chunks but no dimension; dropping stale metadata"); + data.chunks.clear(); + return Ok(()); + } + + let vector_path = vector_data_path(path); + let bytes = match tokio::fs::read(&vector_path).await { + Ok(bytes) => bytes, + Err(error) => { + diag_warn!( + "vector sidecar {} unreadable ({error}); captures need re-indexing", + vector_path.display() + ); + data.chunks.clear(); + return Ok(()); + } + }; + + let stride = data.dim * 4; + let available = (bytes.len() / stride) as u64; + let before = data.chunks.len(); + + // Parked chunks hold no slot, so they survive regardless of what the sidecar has. + data.chunks + .retain(|chunk| chunk.needs_reembed || chunk.vector_slot < available); + for chunk in &mut data.chunks { + if chunk.needs_reembed { + continue; + } + let start = chunk.vector_slot as usize * stride; + chunk.vector = bytes[start..start + stride] + .chunks_exact(4) + .map(|word| f32::from_le_bytes([word[0], word[1], word[2], word[3]])) + .collect(); + } + + if data.chunks.len() != before { + diag_warn!( + "dropped {} chunk(s) missing from the vector sidecar", + before - data.chunks.len() + ); + } + Ok(()) +} + +pub(crate) async fn migrate_legacy_vectors(path: &Path, raw: &str) -> Cmd { + let legacy: LegacyVectorStoreData = match serde_json::from_str(raw) { + Ok(legacy) => legacy, + Err(error) => { + diag_warn!("could not read legacy vector store ({error}); starting fresh"); + return Ok(VectorStoreData::default()); + } + }; + + // v1 imposed no single width, so a store written across an embedding-model change + // holds a mix. v2 has one stride, so the migration must pick one width and park the + // rest. Choosing the width the most chunks use preserves the most vectors; picking + // the first chunk's width would hand the store to whichever model happened to be + // written first, which on a real install was the *older* model. + let mut data = VectorStoreData { + dim: majority_vector_dim(&legacy.chunks), + ..VectorStoreData::default() + }; + data.push_chunks(legacy.chunks.into_iter().map(|chunk| ChunkRecord { + id: chunk.id, + vector: chunk.vector, + vector_slot: 0, + needs_reembed: false, + text: chunk.text, + collection_id: chunk.collection_id, + capture_id: chunk.capture_id, + title: chunk.title, + url: chunk.url, + app_id: chunk.app_id, + captured_at: chunk.captured_at, + chunk_index: chunk.chunk_index, + })); + + let parked = data.pending_reembed_count(); + diag_info!( + "migrating {} chunk(s) to the binary vector store at {} dims{}", + data.chunks.len(), + data.dim, + if parked > 0 { + format!(", parking {parked} from another embedding model for re-indexing") + } else { + String::new() + } + ); + // Keep the pre-migration file under a name no ordinary save touches. The `.bak` + // rotation is one generation deep, so the next capture would overwrite a v1 backup + // with a v2 copy and leave no way back to the original vectors. + archive_pre_migration_store(path, raw).await; + write_vector_sidecar(path, &data, 0).await?; + save_vector_metadata(path, &data).await?; + Ok(data) +} + +// The width the most chunks share, so the migration keeps the largest set of usable +// vectors. Ties break toward the wider vector, which is the newer model in practice. +pub(crate) fn majority_vector_dim(chunks: &[LegacyChunkRecord]) -> usize { + let mut counts: HashMap = HashMap::new(); + for chunk in chunks { + if !chunk.vector.is_empty() { + *counts.entry(chunk.vector.len()).or_insert(0) += 1; + } + } + counts + .into_iter() + .max_by_key(|(dim, count)| (*count, *dim)) + .map(|(dim, _)| dim) + .unwrap_or(0) +} + +// Writes the untouched v1 bytes alongside the store, once. A second migration must not +// clobber the first archive: that copy is the only remaining source for any vector the +// migration parked. +pub(crate) async fn archive_pre_migration_store(path: &Path, raw: &str) { + let mut name = path.file_stem().unwrap_or_default().to_os_string(); + name.push(PRE_MIGRATION_SUFFIX); + let target = path.with_file_name(name); + if tokio::fs::try_exists(&target).await.unwrap_or(false) { + return; + } + match write_bytes_atomically(&target, raw.as_bytes(), false).await { + Ok(()) => diag_error!( + "archived the pre-migration vector store at {}", + target.display() + ), + Err(error) => diag_error!("could not archive the pre-migration store: {error}"), + } +} + +pub(crate) async fn with_vectors_read( + state: &State<'_, Backend>, + read: impl FnOnce(&VectorStoreData) -> T, +) -> Cmd { + { + let guard = state.vectors.read().await; + if let Some(vectors) = guard.as_ref() { + return Ok(read(vectors)); + } + } + let mut guard = state.vectors.write().await; + if guard.is_none() { + *guard = Some(load_vectors(&state.paths.chunks_path).await?); + } + Ok(read(guard.as_ref().expect("vector store cache"))) +} + +pub(crate) async fn with_vectors_mut( + state: &State<'_, Backend>, + mutate: impl FnOnce(&mut VectorStoreData) -> T, +) -> Cmd { + let mut guard = state.vectors.write().await; + if guard.is_none() { + *guard = Some(load_vectors(&state.paths.chunks_path).await?); + } + let vectors = guard.as_mut().expect("vector store cache"); + let result = mutate(vectors); + save_vectors(&state.paths.chunks_path, vectors).await?; + Ok(result) +} + +// Vector rows are large and machine-managed, so the metadata is persisted as compact +// JSON instead of the pretty format used for small user-editable stores. +pub(crate) async fn save_vector_metadata(path: &Path, data: &VectorStoreData) -> Cmd<()> { + let raw = serde_json::to_string(data).map_err(|error| error.to_string())?; + write_store_durably(path, raw.as_bytes()).await +} + +// Writes vectors for every chunk whose slot is at or beyond `slots_present`. +// `slots_present == 0` rewrites the sidecar from scratch. +pub(crate) async fn write_vector_sidecar( + path: &Path, + data: &VectorStoreData, + slots_present: u64, +) -> Cmd<()> { + let vector_path = vector_data_path(path); + ensure_parent_dir(&vector_path).await?; + + let mut pending = data + .chunks + .iter() + .filter(|chunk| !chunk.needs_reembed && chunk.vector_slot >= slots_present) + .collect::>(); + if pending.is_empty() && slots_present > 0 { + return Ok(()); + } + // Slot order is file order; appending out of order would corrupt the stride index. + pending.sort_by_key(|chunk| chunk.vector_slot); + + let mut buffer = Vec::with_capacity(pending.len() * data.dim.max(1) * 4); + for chunk in pending { + for value in &chunk.vector { + buffer.extend_from_slice(&value.to_le_bytes()); + } + } + + let mut file = tokio::fs::OpenOptions::new() + .create(true) + .write(true) + .append(slots_present > 0) + .truncate(slots_present == 0) + .open(&vector_path) + .await + .map_err(|error| error.to_string())?; + file.write_all(&buffer) + .await + .map_err(|error| error.to_string())?; + // The metadata written next will reference these slots, so they must be on disk + // before it lands. Orphaned vector bytes are harmless; dangling slots are not. + file.sync_all().await.map_err(|error| error.to_string()) +} + +pub(crate) async fn sidecar_slots_present(path: &Path, dim: usize) -> u64 { + if dim == 0 { + return 0; + } + match tokio::fs::metadata(vector_data_path(path)).await { + Ok(meta) => meta.len() / (dim as u64 * 4), + Err(_) => 0, + } +} + +// Deletes leave dead slots behind. Once they dominate the sidecar, renumber the live +// chunks and rewrite it so the file cannot grow without bound. +pub(crate) async fn compact_vectors_if_needed( + path: &Path, + data: &mut VectorStoreData, +) -> Cmd { + // Parked chunks hold no slot, so counting them as live would understate how much + // of the sidecar is dead and stop compaction from ever triggering. + let live = data.embedded_count(); + if data.next_slot < VECTOR_COMPACTION_MIN_SLOTS { + return Ok(false); + } + let dead = data.next_slot.saturating_sub(live); + if (dead as f64) < data.next_slot as f64 * VECTOR_COMPACTION_DEAD_RATIO { + return Ok(false); + } + + // Embedded chunks first in slot order, parked ones after, so renumbering walks + // exactly the records that occupy the sidecar. + data.chunks + .sort_by_key(|chunk| (chunk.needs_reembed, chunk.vector_slot)); + let mut next = 0_u64; + for chunk in data.chunks.iter_mut() { + if chunk.needs_reembed { + continue; + } + chunk.vector_slot = next; + next += 1; + } + data.next_slot = live; + diag_info!("compacted vector store, reclaimed {dead} dead slot(s)"); + write_vector_sidecar(path, data, 0).await?; + Ok(true) +} + +pub(crate) async fn save_vectors(path: &Path, data: &mut VectorStoreData) -> Cmd<()> { + if !compact_vectors_if_needed(path, data).await? { + let slots_present = sidecar_slots_present(path, data.dim).await; + write_vector_sidecar(path, data, slots_present).await?; + } + save_vector_metadata(path, data).await +} diff --git a/src-tauri/src/webview.rs b/src-tauri/src/webview.rs new file mode 100644 index 0000000..3eb1e2b --- /dev/null +++ b/src-tauri/src/webview.rs @@ -0,0 +1,1170 @@ +//! Native web content: one child webview per tab on desktop, one Android WebView +//! per tab on mobile, driven through the same `*_native_webview` functions. +//! +//! Each function is paired: a `#[cfg(desktop)]` implementation and a mobile one. +//! Also holds the two injected page scripts (find-in-page, scroll-to-text), which +//! are the only JavaScript ÆTHER runs inside a visited page. + +use super::*; + +#[cfg(desktop)] +pub(crate) fn ensure_native_webview( + app: &AppHandle, + state: &State, + tab_id: &str, +) -> Cmd<()> { + let tab = { + let tabs = lock_tabs(state)?; + tabs.tabs + .iter() + .find(|tab| tab.id == tab_id) + .cloned() + .ok_or_else(|| format!("Unknown tab: {tab_id}"))? + }; + + // A blank start-page tab has no remote page to load — just reconcile visibility so + // any previously-active webview is hidden and the renderer's start page shows. + if tab.url == START_PAGE_URL { + return sync_native_webview_visibility(app, state); + } + + let exists = state + .webviews + .lock() + .map_err(|_| "webviews are unavailable.".to_string())? + .views + .contains_key(tab_id); + if !exists { + let webview = create_native_webview(app, state, &tab)?; + state + .webviews + .lock() + .map_err(|_| "webviews are unavailable.".to_string())? + .views + .insert(tab.id.clone(), webview); + } + + sync_native_webview_visibility(app, state) +} + +#[cfg(not(desktop))] +pub(crate) fn ensure_native_webview( + app: &AppHandle, + state: &State, + tab_id: &str, +) -> Cmd<()> { + #[cfg(target_os = "android")] + { + let tab = { + let tabs = lock_tabs(state)?; + tabs.tabs + .iter() + .find(|tab| tab.id == tab_id) + .cloned() + .ok_or_else(|| format!("Unknown tab: {tab_id}"))? + }; + // Like the desktop path: a start-page tab has nothing to load, only + // visibility to reconcile so the renderer's start page shows. + if tab.url != START_PAGE_URL { + app.state::().run( + "ensure", + android_tabs::TabUrlPayload { + tab_id: &tab.id, + url: &tab.url, + }, + )?; + } + return sync_native_webview_visibility(app, state); + } + #[allow(unreachable_code)] + { + let _ = (app, state, tab_id); + Ok(()) + } +} + +#[cfg(desktop)] +pub(crate) fn create_native_webview( + app: &AppHandle, + state: &State, + tab: &ManagedTab, +) -> Cmd { + let window = app + .get_window("main") + .ok_or_else(|| "main window is not ready.".to_string())?; + let bounds = native_webview_bounds(&window, state)?; + let label = native_webview_label(&tab.id); + let tab_id_for_navigation = tab.id.clone(); + let tab_id_for_load = tab.id.clone(); + let tab_id_for_title = tab.id.clone(); + let app_for_navigation = app.clone(); + let app_for_load = app.clone(); + let app_for_title = app.clone(); + let app_for_new_window = app.clone(); + let app_for_download = app.clone(); + let url = Url::parse(&tab.url).map_err(|error| error.to_string())?; + + let builder = WebviewBuilder::new(label, WebviewUrl::External(url)) + .user_agent(DESKTOP_BROWSER_USER_AGENT) + .on_navigation(move |url| { + let state = app_for_navigation.state::(); + update_tab_navigation_state(&state, &tab_id_for_navigation, url.as_str(), true); + let _ = emit_state(&app_for_navigation, &state); + true + }) + .on_page_load(move |webview, payload| { + let state = app_for_load.state::(); + let is_loading = payload.event() == PageLoadEvent::Started; + update_tab_navigation_state( + &state, + &tab_id_for_load, + payload.url().as_str(), + is_loading, + ); + let _ = emit_state(&app_for_load, &state); + if payload.event() == PageLoadEvent::Finished { + // Records the settled URL and title, which is what a restore needs. + schedule_session_save(&app_for_load); + let _ = webview.eval(NATIVE_WEBVIEW_SCROLLBAR_SCRIPT); + read_native_webview_metadata( + &webview, + app_for_load.clone(), + tab_id_for_load.clone(), + ); + } + }) + .on_document_title_changed(move |_webview, title| { + let state = app_for_title.state::(); + update_tab_title(&state, &tab_id_for_title, &title); + let _ = emit_state(&app_for_title, &state); + }) + .on_new_window(move |url, _features| { + let state = app_for_new_window.state::(); + let _ = create_native_tab_from_url(&app_for_new_window, &state, url.as_str()); + NewWindowResponse::Deny + }) + // Without this hook the webview silently drops downloads: clicking a PDF or + // zip link did nothing at all, with no error and no file. + .on_download(move |_webview, event| match event { + DownloadEvent::Requested { url, destination } => { + match resolve_download_destination(&app_for_download, &url) { + Some(target) => { + let filename = file_name_of(&target); + app_for_download + .state::() + .pending_downloads + .lock() + .map(|mut pending| { + pending.insert(url.to_string(), target.clone()); + }) + .ok(); + *destination = target; + emit_download_event( + &app_for_download, + "started", + &filename, + None, + url.as_str(), + ); + true + } + None => { + diag_error!("no writable downloads directory; refusing download"); + emit_download_event( + &app_for_download, + "failed", + &file_name_from_url(&url), + None, + url.as_str(), + ); + false + } + } + } + DownloadEvent::Finished { url, path, success } => { + // macOS never reports the path here, so fall back to the destination + // recorded at request time. + let recorded = app_for_download + .state::() + .pending_downloads + .lock() + .ok() + .and_then(|mut pending| pending.remove(&url.to_string())); + let resolved = path.or(recorded); + let filename = resolved + .as_deref() + .map(file_name_of) + .unwrap_or_else(|| file_name_from_url(&url)); + emit_download_event( + &app_for_download, + if success { "finished" } else { "failed" }, + &filename, + resolved.as_deref(), + url.as_str(), + ); + true + } + _ => true, + }); + + let webview = window + .add_child(builder, bounds.position, bounds.size) + .map_err(|error| error.to_string())?; + webview.hide().map_err(|error| error.to_string())?; + Ok(webview) +} + +#[cfg(desktop)] +pub(crate) fn create_native_tab_from_url( + app: &AppHandle, + state: &State, + raw_url: &str, +) -> Cmd<()> { + let url = normalize_url(raw_url, "google"); + let tab = ManagedTab::new("browser", &url); + let tab_id = tab.id.clone(); + { + let mut tabs = lock_tabs(state)?; + tabs.active_tab_id = tab_id.clone(); + tabs.active_app_id = tab.app_id.clone(); + tabs.dashboard_open = false; + tabs.tabs.push(tab); + } + ensure_native_webview(app, state, &tab_id)?; + emit_state(app, state) +} + +#[cfg(desktop)] +pub(crate) fn navigate_native_webview( + app: &AppHandle, + state: &State, + tab_id: &str, + url: &str, +) -> Cmd<()> { + ensure_native_webview(app, state, tab_id)?; + let parsed = Url::parse(url).map_err(|error| error.to_string())?; + let webview = state + .webviews + .lock() + .map_err(|_| "webviews are unavailable.".to_string())? + .views + .get(tab_id) + .cloned() + .ok_or_else(|| format!("Native webview not found for tab: {tab_id}"))?; + webview.navigate(parsed).map_err(|error| error.to_string()) +} + +#[cfg(not(desktop))] +pub(crate) fn navigate_native_webview( + app: &AppHandle, + state: &State, + tab_id: &str, + url: &str, +) -> Cmd<()> { + #[cfg(target_os = "android")] + { + app.state::() + .run("navigate", android_tabs::TabUrlPayload { tab_id, url })?; + return sync_native_webview_visibility(app, state); + } + #[allow(unreachable_code)] + { + let _ = (app, state, tab_id, url); + Ok(()) + } +} + +#[cfg(desktop)] +pub(crate) fn navigate_native_webview_history( + _app: &AppHandle, + state: &State, + tab_id: &str, + direction: WebviewHistoryDirection, +) -> Cmd<()> { + let webview = state + .webviews + .lock() + .map_err(|_| "webviews are unavailable.".to_string())? + .views + .get(tab_id) + .cloned() + .ok_or_else(|| format!("Native webview not found for tab: {tab_id}"))?; + let script = match direction { + WebviewHistoryDirection::Back => "history.back();", + WebviewHistoryDirection::Forward => "history.forward();", + }; + webview.eval(script).map_err(|error| error.to_string()) +} + +#[cfg(not(desktop))] +pub(crate) fn navigate_native_webview_history( + app: &AppHandle, + state: &State, + tab_id: &str, + direction: WebviewHistoryDirection, +) -> Cmd<()> { + #[cfg(target_os = "android")] + { + let _ = state; + let command = match direction { + WebviewHistoryDirection::Back => "goBack", + WebviewHistoryDirection::Forward => "goForward", + }; + return app + .state::() + .run(command, android_tabs::TabPayload { tab_id }); + } + #[allow(unreachable_code)] + { + let _ = (app, state, tab_id, direction); + Ok(()) + } +} + +#[cfg(desktop)] +pub(crate) fn scroll_native_webview_to_text( + _app: &AppHandle, + state: &State, + tab_id: &str, + text: &str, +) -> Cmd<()> { + let source_text = text.trim(); + if source_text.is_empty() { + return Ok(()); + } + let webview = state + .webviews + .lock() + .map_err(|_| "webviews are unavailable.".to_string())? + .views + .get(tab_id) + .cloned() + .ok_or_else(|| format!("Native webview not found for tab: {tab_id}"))?; + let text_json = serde_json::to_string(source_text).map_err(|error| error.to_string())?; + let script = scroll_to_text_script().replace("__AETHER_SOURCE_TEXT__", &text_json); + webview.eval(script).map_err(|error| error.to_string()) +} + +#[cfg(not(desktop))] +pub(crate) fn scroll_native_webview_to_text( + app: &AppHandle, + state: &State, + tab_id: &str, + text: &str, +) -> Cmd<()> { + #[cfg(target_os = "android")] + { + let _ = state; + let source_text = text.trim(); + if source_text.is_empty() { + return Ok(()); + } + let text_json = serde_json::to_string(source_text).map_err(|error| error.to_string())?; + let script = scroll_to_text_script().replace("__AETHER_SOURCE_TEXT__", &text_json); + return app + .state::() + .run("eval", android_tabs::EvalPayload { tab_id, script }); + } + #[allow(unreachable_code)] + { + let _ = (app, state, tab_id, text); + Ok(()) + } +} + +#[cfg(desktop)] +pub(crate) fn find_native_webview_text( + app: &AppHandle, + state: &State, + tab_id: &str, + query: Option<&str>, + action: &str, +) -> Cmd<()> { + let webview = state + .webviews + .lock() + .map_err(|_| "webviews are unavailable.".to_string())? + .views + .get(tab_id) + .cloned() + .ok_or_else(|| format!("Native webview not found for tab: {tab_id}"))?; + let query_json = match query.map(str::trim).filter(|value| !value.is_empty()) { + Some(value) => serde_json::to_string(value).map_err(|error| error.to_string())?, + None => "null".to_string(), + }; + let action_json = serde_json::to_string(action).map_err(|error| error.to_string())?; + let script = find_in_page_script() + .replace("__AETHER_FIND_QUERY__", &query_json) + .replace("__AETHER_FIND_ACTION__", &action_json); + let app = app.clone(); + let tab_id = tab_id.to_string(); + webview + .eval_with_callback(script, move |payload| { + let Ok(snapshot) = parse_json_payload::(&payload) else { + return; + }; + let _ = app.emit( + AETHER_FIND_RESULT_EVENT, + FindResultPayload { + tab_id: tab_id.clone(), + current: snapshot.current, + total: snapshot.total, + }, + ); + }) + .map_err(|error| error.to_string()) +} + +#[cfg(not(desktop))] +pub(crate) fn find_native_webview_text( + app: &AppHandle, + state: &State, + tab_id: &str, + query: Option<&str>, + action: &str, +) -> Cmd<()> { + #[cfg(target_os = "android")] + { + let _ = state; + // Android WebView has native find support (findAllAsync/findNext); the + // match counts come back through the FindListener as a "find" event on + // aether_tabs_report_native_event. + return app.state::().run( + "find", + android_tabs::FindPayload { + tab_id, + query: query.map(str::trim).filter(|value| !value.is_empty()), + action, + }, + ); + } + #[allow(unreachable_code)] + { + let _ = (app, state, tab_id, query, action); + Ok(()) + } +} + +#[cfg(desktop)] +pub(crate) fn close_native_webview( + _app: &AppHandle, + state: &State, + tab_id: &str, +) -> Cmd<()> { + if let Some(webview) = state + .webviews + .lock() + .map_err(|_| "webviews are unavailable.".to_string())? + .views + .remove(tab_id) + { + webview.close().map_err(|error| error.to_string())?; + } + Ok(()) +} + +#[cfg(not(desktop))] +pub(crate) fn close_native_webview( + app: &AppHandle, + state: &State, + tab_id: &str, +) -> Cmd<()> { + #[cfg(target_os = "android")] + { + let _ = state; + return app + .state::() + .run("close", android_tabs::TabPayload { tab_id }); + } + #[allow(unreachable_code)] + { + let _ = (app, state, tab_id); + Ok(()) + } +} + +#[cfg(desktop)] +pub(crate) fn find_in_page_script() -> &'static str { + r#" +(() => { + const action = __AETHER_FIND_ACTION__; + const rawQuery = __AETHER_FIND_QUERY__; + const HL = 'aether-find'; + const HL_CUR = 'aether-find-current'; + const STYLE_ID = 'aether-find-style'; + const MAX = 5000; + const supportsHighlight = + typeof CSS !== 'undefined' && + CSS.highlights && + typeof Highlight !== 'undefined' && + typeof Range !== 'undefined'; + const normalize = (value) => String(value ?? '').replace(/\s+/g, ' ').trim(); + const state = (window.__aetherFind = window.__aetherFind || { query: '', index: 0, total: 0 }); + + const clearHighlights = () => { + if (supportsHighlight) { + try { CSS.highlights.delete(HL); CSS.highlights.delete(HL_CUR); } catch (error) {} + } + document.querySelectorAll('mark[data-aether-find]').forEach((mark) => { + const parent = mark.parentNode; + if (!parent) return; + while (mark.firstChild) parent.insertBefore(mark.firstChild, mark); + parent.removeChild(mark); + parent.normalize(); + }); + }; + + const ensureStyle = () => { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement('style'); + style.id = STYLE_ID; + style.textContent = + '::highlight(aether-find){background-color:#bfe9f7;color:#0e364a;}' + + '::highlight(aether-find-current){background-color:#247fa7;color:#f4fbff;}' + + 'mark[data-aether-find]{background-color:#bfe9f7;color:#0e364a;border-radius:2px;padding:0;}' + + 'mark[data-aether-find="current"]{background-color:#247fa7;color:#f4fbff;}'; + (document.head || document.documentElement).appendChild(style); + }; + + if (action === 'clear') { + clearHighlights(); + state.query = ''; state.index = 0; state.total = 0; + return { current: 0, total: 0 }; + } + + const query = normalize(rawQuery); + clearHighlights(); + if (!query) { + state.query = ''; state.index = 0; state.total = 0; + return { current: 0, total: 0 }; + } + + const collectRanges = (needle) => { + const lc = needle.toLowerCase(); + const len = lc.length; + const root = document.body || document.documentElement; + if (!root || !len) return []; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + if (!node.nodeValue) return NodeFilter.FILTER_REJECT; + const parent = node.parentElement; + if (!parent) return NodeFilter.FILTER_REJECT; + const tag = parent.tagName; + if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT' || tag === 'TEXTAREA') { + return NodeFilter.FILTER_REJECT; + } + return NodeFilter.FILTER_ACCEPT; + } + }); + const nodes = []; + let buffer = ''; + let node; + while ((node = walker.nextNode())) { + nodes.push({ node, start: buffer.length }); + buffer += node.nodeValue; + } + const haystack = buffer.toLowerCase(); + const nodeAt = (offset) => { + let lo = 0, hi = nodes.length - 1, pick = 0; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (nodes[mid].start <= offset) { pick = mid; lo = mid + 1; } else { hi = mid - 1; } + } + return pick; + }; + const ranges = []; + let from = 0, at; + while ((at = haystack.indexOf(lc, from)) !== -1) { + const end = at + len; + const startNode = nodeAt(at); + const endNode = nodeAt(end - 1); + try { + const range = document.createRange(); + range.setStart(nodes[startNode].node, at - nodes[startNode].start); + range.setEnd(nodes[endNode].node, end - nodes[endNode].start); + ranges.push(range); + } catch (error) {} + from = end; + if (ranges.length >= MAX) break; + } + return ranges; + }; + + const ranges = collectRanges(query); + const total = ranges.length; + if (total === 0) { + state.query = query; state.index = 0; state.total = 0; + return { current: 0, total: 0 }; + } + + let index; + if ((action === 'next' || action === 'prev') && state.query === query) { + index = state.index + (action === 'next' ? 1 : -1); + } else { + index = 0; + } + index = ((index % total) + total) % total; + state.query = query; state.index = index; state.total = total; + + ensureStyle(); + if (supportsHighlight) { + try { + const all = new Highlight(); + for (const range of ranges) all.add(range); + CSS.highlights.set(HL, all); + const current = new Highlight(); + current.add(ranges[index]); + CSS.highlights.set(HL_CUR, current); + } catch (error) {} + } else { + for (let i = ranges.length - 1; i >= 0; i--) { + try { + const mark = document.createElement('mark'); + mark.setAttribute('data-aether-find', i === index ? 'current' : 'all'); + ranges[i].surroundContents(mark); + } catch (error) {} + } + } + + let scrollTarget = null; + if (supportsHighlight) { + const node = ranges[index].startContainer; + scrollTarget = node.nodeType === 1 ? node : node.parentElement; + } else { + scrollTarget = document.querySelector('mark[data-aether-find="current"]'); + } + try { + if (scrollTarget && scrollTarget.scrollIntoView) { + scrollTarget.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'smooth' }); + } + } catch (error) {} + + return { current: index + 1, total }; +})() +"# +} + +pub(crate) fn scroll_to_text_script() -> &'static str { + r#" +(() => { + const sourceText = __AETHER_SOURCE_TEXT__; + const normalize = (value) => String(value || '').replace(/\s+/g, ' ').trim().toLowerCase(); + const source = normalize(sourceText); + if (!source) return; + const EXACT_HL = 'aether-source-exact'; + const STYLE_ID = 'aether-source-style'; + const supportsHighlight = + typeof CSS !== 'undefined' && + CSS.highlights && + typeof Highlight !== 'undefined' && + typeof Range !== 'undefined'; + + const words = source.split(' ').filter(Boolean).slice(0, 180); + const snippets = []; + const seen = new Set(); + const addSnippet = (start, length) => { + const snippet = words.slice(start, start + length).join(' '); + if (snippet.length >= 32 && !seen.has(snippet)) { + seen.add(snippet); + snippets.push(snippet); + } + }; + + for (const length of [28, 22, 16, 12, 9, 7]) { + const step = Math.max(3, Math.floor(length / 2)); + for (let start = 0; start < words.length; start += step) { + addSnippet(start, length); + } + } + snippets.sort((left, right) => right.length - left.length); + + const ensureStyle = () => { + if (document.getElementById(STYLE_ID)) return; + const style = document.createElement('style'); + style.id = STYLE_ID; + style.textContent = + '::highlight(aether-source-exact){background-color:rgba(255,224,102,0.72);color:inherit;}' + + 'mark[data-aether-source-range]{background-color:rgba(255,224,102,0.72);color:inherit;border-radius:2px;padding:0;}'; + (document.head || document.documentElement).appendChild(style); + }; + + const clearExactHighlights = () => { + if (supportsHighlight) { + try { CSS.highlights.delete(EXACT_HL); } catch (error) {} + } + document.querySelectorAll('mark[data-aether-source-range]').forEach((mark) => { + const parent = mark.parentNode; + if (!parent) return; + while (mark.firstChild) parent.insertBefore(mark.firstChild, mark); + parent.removeChild(mark); + parent.normalize(); + }); + }; + + const restorePreviousHighlights = () => { + clearExactHighlights(); + document.querySelectorAll('[data-aether-source-highlight="true"]').forEach((element) => { + element.style.outline = element.dataset.aetherPreviousOutline || ''; + element.style.boxShadow = element.dataset.aetherPreviousBoxShadow || ''; + element.style.backgroundColor = element.dataset.aetherPreviousBackgroundColor || ''; + element.removeAttribute('data-aether-source-highlight'); + element.removeAttribute('data-aether-previous-outline'); + element.removeAttribute('data-aether-previous-box-shadow'); + element.removeAttribute('data-aether-previous-background-color'); + }); + }; + + const textNodeAccepted = (node) => { + if (!node.nodeValue) return false; + const parent = node.parentElement; + if (!parent) return false; + const tag = parent.tagName; + return tag !== 'SCRIPT' && tag !== 'STYLE' && tag !== 'NOSCRIPT' && tag !== 'TEXTAREA'; + }; + + const collectTextIndex = () => { + const root = document.body || document.documentElement; + if (!root) return null; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { + acceptNode(node) { + return textNodeAccepted(node) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT; + } + }); + const map = []; + let text = ''; + let node; + while ((node = walker.nextNode())) { + const value = node.nodeValue || ''; + for (let index = 0; index < value.length; index += 1) { + const char = value[index]; + if (/\s/.test(char)) { + if (text && !text.endsWith(' ')) { + text += ' '; + map.push({ node, offset: index }); + } + } else { + text += char.toLowerCase(); + map.push({ node, offset: index }); + } + } + } + while (text.endsWith(' ')) { + text = text.slice(0, -1); + map.pop(); + } + return { text, map }; + }; + + const rangeFromIndex = (index, length, map) => { + const start = map[index]; + const end = map[index + length - 1]; + if (!start || !end) return null; + try { + const range = document.createRange(); + range.setStart(start.node, start.offset); + range.setEnd(end.node, end.offset + 1); + return range; + } catch (error) { + return null; + } + }; + + const findRangeMatch = () => { + const index = collectTextIndex(); + if (!index) return null; + for (const snippet of snippets) { + const at = index.text.indexOf(snippet); + if (at === -1) continue; + const range = rangeFromIndex(at, snippet.length, index.map); + if (range) return range; + } + return null; + }; + + const scrollRangeIntoView = (range) => { + try { + const rects = range.getClientRects(); + const rect = rects.length ? rects[0] : range.getBoundingClientRect(); + if (rect && Number.isFinite(rect.top)) { + const top = rect.top + window.scrollY - window.innerHeight * 0.42; + window.scrollTo({ top: Math.max(0, top), behavior: 'smooth' }); + return; + } + } catch (error) {} + const node = range.startContainer; + const element = node.nodeType === 1 ? node : node.parentElement; + if (element && element.scrollIntoView) { + element.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'smooth' }); + } + }; + + const highlightRange = (range) => { + restorePreviousHighlights(); + ensureStyle(); + let highlighted = false; + if (supportsHighlight) { + try { + const exact = new Highlight(); + exact.add(range); + CSS.highlights.set(EXACT_HL, exact); + highlighted = true; + } catch (error) {} + } + if (!highlighted) { + try { + const mark = document.createElement('mark'); + mark.setAttribute('data-aether-source-range', 'true'); + range.surroundContents(mark); + highlighted = true; + } catch (error) {} + } + scrollRangeIntoView(range); + window.setTimeout(clearExactHighlights, 12000); + return highlighted; + }; + + const isVisible = (element) => { + const style = window.getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.display !== 'none' && style.visibility !== 'hidden' && rect.width > 0 && rect.height > 0; + }; + + const scoreElement = (element) => { + const tag = element.tagName.toLowerCase(); + if (['p', 'li', 'blockquote', 'td', 'th', 'figcaption', 'dd', 'dt'].includes(tag)) return 0; + if (['article', 'section', 'main'].includes(tag)) return 2; + return 1; + }; + + const highlight = (element) => { + restorePreviousHighlights(); + element.dataset.aetherSourceHighlight = 'true'; + element.dataset.aetherPreviousOutline = element.style.outline || ''; + element.dataset.aetherPreviousBoxShadow = element.style.boxShadow || ''; + element.dataset.aetherPreviousBackgroundColor = element.style.backgroundColor || ''; + element.style.outline = '3px solid rgba(66, 153, 225, 0.72)'; + element.style.boxShadow = '0 0 0 8px rgba(66, 153, 225, 0.16)'; + element.style.backgroundColor = 'rgba(255, 246, 189, 0.42)'; + element.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'smooth' }); + window.setTimeout(() => { + if (element.dataset.aetherSourceHighlight === 'true') restorePreviousHighlights(); + }, 12000); + }; + + const findMatch = () => { + const elements = Array.from( + document.querySelectorAll('p, li, blockquote, td, th, figcaption, dd, dt, article, section, main, div') + ) + .filter(isVisible) + .map((element) => ({ element, text: normalize(element.textContent) })) + .filter((item) => item.text.length >= 32) + .sort((left, right) => { + const tagScore = scoreElement(left.element) - scoreElement(right.element); + if (tagScore !== 0) return tagScore; + return left.text.length - right.text.length; + }); + + for (const snippet of snippets) { + const match = elements.find((item) => item.text.includes(snippet)); + if (match) return match.element; + } + + return null; + }; + + let attempts = 0; + const retry = () => { + attempts += 1; + const range = findRangeMatch(); + if (range && highlightRange(range)) return; + const match = findMatch(); + if (match) { + highlight(match); + return; + } + if (attempts < 28) window.setTimeout(retry, 250); + }; + + retry(); +})(); +"# +} + +#[cfg(desktop)] +pub(crate) fn resize_native_webviews(app: &AppHandle, state: &State) -> Cmd<()> { + sync_native_webview_visibility(app, state) +} + +#[cfg(desktop)] +pub(crate) fn sync_native_webview_visibility(app: &AppHandle, state: &State) -> Cmd<()> { + let (active_tab_id, show_active, panel_collapsed) = { + let tabs = lock_tabs(state)?; + // A tab parked on the start page must keep its (possibly still-alive) webview + // hidden so the renderer's start page overlay stays visible. + let active_is_start = tabs + .active_tab() + .map(|tab| tab.url == START_PAGE_URL) + .unwrap_or(false); + ( + tabs.active_tab_id.clone(), + !tabs.dashboard_open && !tabs.modal_overlay_open && !active_is_start, + tabs.panel_collapsed, + ) + }; + // Prefer the renderer-measured slot; fall back to the layout constants until the + // first report arrives. + let bounds = match reported_webview_bounds(state) { + Some(bounds) => bounds, + None => { + let window = app + .get_window("main") + .ok_or_else(|| "main window is not ready.".to_string())?; + native_webview_bounds_for_window(&window, panel_collapsed)? + } + }; + let webviews = state + .webviews + .lock() + .map_err(|_| "webviews are unavailable.".to_string())?; + + for (tab_id, webview) in &webviews.views { + if show_active && tab_id == &active_tab_id { + webview + .set_bounds(bounds) + .map_err(|error| error.to_string())?; + webview.show().map_err(|error| error.to_string())?; + } else { + webview.hide().map_err(|error| error.to_string())?; + } + } + + Ok(()) +} + +#[cfg(not(desktop))] +pub(crate) fn sync_native_webview_visibility(app: &AppHandle, state: &State) -> Cmd<()> { + #[cfg(target_os = "android")] + { + let (active_tab_id, show_active) = { + let tabs = lock_tabs(state)?; + // Same rules as desktop: keep webviews hidden behind the dashboard, + // modal overlays, and the renderer's start-page overlay. + let active_is_start = tabs + .active_tab() + .map(|tab| tab.url == START_PAGE_URL) + .unwrap_or(false); + ( + tabs.active_tab_id.clone(), + !tabs.dashboard_open && !tabs.modal_overlay_open && !active_is_start, + ) + }; + let bounds = *state + .web_content_bounds + .lock() + .map_err(|_| "layout bounds are unavailable.".to_string())?; + return app.state::().run( + "sync", + android_tabs::SyncPayload { + active_tab_id: show_active.then_some(active_tab_id.as_str()), + top: bounds.top, + left: bounds.left, + width: bounds.width, + height: bounds.height, + }, + ); + } + #[allow(unreachable_code)] + { + let _ = (app, state); + Ok(()) + } +} + +#[cfg(desktop)] +pub(crate) fn native_webview_bounds(window: &Window, state: &State) -> Cmd { + if let Some(bounds) = reported_webview_bounds(state) { + return Ok(bounds); + } + let panel_collapsed = lock_tabs(state)?.panel_collapsed; + native_webview_bounds_for_window(window, panel_collapsed) +} + +#[cfg(desktop)] +pub(crate) fn native_webview_bounds_for_window( + window: &Window, + panel_collapsed: bool, +) -> Cmd { + let size = window + .inner_size() + .map_err(|error| error.to_string())? + .to_logical::(window.scale_factor().map_err(|error| error.to_string())?); + let right_width = if panel_collapsed { + PANEL_COLLAPSED_WIDTH + } else { + PANEL_WIDTH + }; + let width = (size.width - SIDEBAR_WIDTH - right_width).max(280.0); + let height = (size.height - BROWSER_VIEW_TOP).max(200.0); + + Ok(Rect { + position: Position::Logical(LogicalPosition::new(SIDEBAR_WIDTH, BROWSER_VIEW_TOP)), + size: Size::Logical(LogicalSize::new(width, height)), + }) +} + +// Preferred over the constants above: the renderer measures the actual content slot, +// so the chrome's real height and the panel's real width define the web view instead +// of numbers that silently drift whenever the CSS changes. The constants remain the +// fallback for the first frames, before the renderer has reported anything. +#[cfg(desktop)] +pub(crate) fn reported_webview_bounds(state: &State) -> Option { + let bounds = *state.web_content_bounds.lock().ok()?; + // A zero-size rect means the content slot is not laid out (dashboard open, or the + // very first frame); positioning a webview to it would collapse the view. + if bounds.width < 1.0 || bounds.height < 1.0 { + return None; + } + Some(Rect { + position: Position::Logical(LogicalPosition::new(bounds.left, bounds.top)), + size: Size::Logical(LogicalSize::new(bounds.width, bounds.height)), + }) +} + +#[cfg(desktop)] +pub(crate) fn native_webview_label(tab_id: &str) -> String { + format!("aether-browser-tab-{tab_id}") +} + +#[cfg(desktop)] +pub(crate) fn read_native_webview_metadata(webview: &Webview, app: AppHandle, tab_id: String) { + let script = r#"(() => { + const theme = document.querySelector('meta[name="theme-color"], meta[name="msapplication-TileColor"]'); + const icons = Array.from(document.querySelectorAll('link[rel]')) + .map((link) => { + const rel = link.getAttribute('rel') || ''; + if (!/\b(icon|apple-touch-icon|shortcut icon)\b/i.test(rel)) return null; + const href = link.href || ''; + const sizes = link.getAttribute('sizes') || ''; + const size = sizes + .split(/\s+/) + .map((item) => Number.parseInt(item, 10) || 0) + .reduce((largest, value) => Math.max(largest, value), 0); + return { href, rel, size }; + }) + .filter(Boolean) + .sort((left, right) => { + if (right.size !== left.size) return right.size - left.size; + return Number(/apple-touch-icon/i.test(right.rel)) - Number(/apple-touch-icon/i.test(left.rel)); + }); + return { + themeColor: theme?.getAttribute('content') || '', + favicon: icons[0]?.href || '' + }; + })()"#; + + let _ = webview.eval_with_callback(script, move |payload| { + let metadata = match parse_json_payload::(&payload) { + Ok(metadata) => metadata, + Err(_) => return, + }; + let favicon = metadata + .favicon + .map(|favicon| favicon.trim().to_string()) + .filter(|favicon| !favicon.is_empty()); + let theme_color = metadata + .theme_color + .as_deref() + .and_then(normalize_theme_color); + let state = app.state::(); + if update_tab_metadata(&state, &tab_id, theme_color, favicon) { + let _ = emit_state(&app, &state); + } + }); +} + +pub(crate) fn update_tab_navigation_state( + state: &State, + tab_id: &str, + url: &str, + is_loading: bool, +) { + if let Ok(mut tabs) = lock_tabs(state) { + if let Some(tab) = tabs.tabs.iter_mut().find(|tab| tab.id == tab_id) { + tab.is_loading = is_loading; + let url = url.trim(); + if !should_accept_webview_url(&tab.url, url) { + return; + } + + let url = url.to_string(); + let url_changed = tab.url != url; + tab.url = url.clone(); + tab.favicon = favicon_for_url(&url); + if url_changed { + tab.theme_color = None; + } + if tab.title == "New tab" || tab.title.is_empty() || tab.title == get_tab_host(&tab.url) + { + tab.title = title_from_url(&url); + } + if !is_loading { + tab.commit_history_url(url); + } + } + } +} + +pub(crate) fn should_accept_webview_url(current_url: &str, next_url: &str) -> bool { + if next_url.is_empty() { + return false; + } + // While a tab is parked on the start page, ignore stray events from its hidden + // webview so they don't overwrite the start-page sentinel. + if current_url == START_PAGE_URL { + return false; + } + if is_transient_webview_url(next_url) && !is_transient_webview_url(current_url) { + return false; + } + true +} + +pub(crate) fn is_transient_webview_url(url: &str) -> bool { + let normalized = url.trim().to_ascii_lowercase(); + normalized == "about:blank" + || normalized.starts_with("about:blank#") + || normalized == "about:srcdoc" +} + +#[cfg(desktop)] +pub(crate) fn update_tab_metadata( + state: &State, + tab_id: &str, + theme_color: Option, + favicon: Option, +) -> bool { + if let Ok(mut tabs) = lock_tabs(state) { + if let Some(tab) = tabs.tabs.iter_mut().find(|tab| tab.id == tab_id) { + let favicon = favicon.or_else(|| tab.favicon.clone()); + if tab.theme_color == theme_color && tab.favicon == favicon { + return false; + } + tab.theme_color = theme_color; + tab.favicon = favicon; + return true; + } + } + false +} + +pub(crate) fn update_tab_title(state: &State, tab_id: &str, title: &str) { + let title = title.trim(); + if title.is_empty() { + return; + } + if let Ok(mut tabs) = lock_tabs(state) { + if let Some(tab) = tabs.tabs.iter_mut().find(|tab| tab.id == tab_id) { + tab.title = title.to_string(); + } + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index db81e2b..8b1a15a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -23,7 +23,49 @@ } ], "security": { - "csp": null + "csp": { + "default-src": "'self'", + "script-src": "'self'", + "style-src": ["'self'", "'unsafe-inline'"], + "img-src": ["'self'", "data:", "blob:", "https:", "http:"], + "font-src": "'self'", + "connect-src": ["'self'", "ipc:", "http://ipc.localhost"], + "media-src": ["'self'", "data:", "blob:"], + "object-src": "'none'", + "base-uri": "'self'", + "form-action": "'none'", + "frame-ancestors": "'none'" + }, + "devCsp": { + "default-src": "'self'", + "script-src": ["'self'", "'unsafe-inline'", "'unsafe-eval'"], + "style-src": ["'self'", "'unsafe-inline'"], + "img-src": ["'self'", "data:", "blob:", "https:", "http:"], + "font-src": "'self'", + "connect-src": [ + "'self'", + "ipc:", + "http://ipc.localhost", + "ws://127.0.0.1:1420", + "ws://localhost:1420", + "http://127.0.0.1:1420" + ], + "media-src": ["'self'", "data:", "blob:"], + "object-src": "'none'", + "base-uri": "'self'", + "form-action": "'none'" + } + } + }, + "plugins": { + "updater": { + "endpoints": [ + "https://github.com/CanPixel/aether/releases/latest/download/latest.json" + ], + "pubkey": "", + "windows": { + "installMode": "passive" + } } }, "bundle": { diff --git a/src-tauri/tauri.updater.conf.json b/src-tauri/tauri.updater.conf.json new file mode 100644 index 0000000..d50663d --- /dev/null +++ b/src-tauri/tauri.updater.conf.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "bundle": { + "createUpdaterArtifacts": true + } +} diff --git a/src/renderer/index.html b/src/renderer/index.html index 1f00400..448037a 100644 --- a/src/renderer/index.html +++ b/src/renderer/index.html @@ -7,11 +7,11 @@ ÆTHER - - + diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 995d65d..3afd4f9 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -48,7 +48,11 @@ import { SavedIcebergSummary, StatusToastInput, SystemStatus, - UpdateCheckResult + Appearance, + DiagnosticEntry, + UpdateCheckResult, + UpdateInstallProgress, + UpdateInstallResult } from '../../shared/aether' import { BrowserChrome } from './components/BrowserChrome' import { MobileShell } from './components/MobileShell' @@ -66,6 +70,9 @@ import { ModelSetupModal } from './components/ModelSetupModal' import { QuickAction } from './types/ui' import { cleanTitle, + countLabel, + formatByteSize, + formatUpdateProgress, formatVisibleModelName, getQuickActions, getTabTint, @@ -78,8 +85,10 @@ import { ChevronUp, HardDriveDownload, RefreshCw, + FileText, SearchIcon, Snowflake, + SunMoon, Waves, Wind @@ -93,6 +102,11 @@ import { // Sentinel URL for a blank tab that shows the ÆTHER start page instead of loading a // page. Must match START_PAGE_URL in src-tauri/src/lib.rs. const START_PAGE_URL = 'aether://start' +const APPEARANCE_OPTIONS: Array<{ id: Appearance; name: string; description: string }> = [ + { id: 'system', name: 'System', description: 'Follow the desktop appearance.' }, + { id: 'light', name: 'Light', description: 'The pale glass theme.' }, + { id: 'dark', name: 'Dark', description: 'Deep navy, for night reading.' } +] // AiON panel sizing. Must stay in step with --panel-collapsed-width in foundation.css // and PANEL_COLLAPSED_WIDTH in src-tauri/src/lib.rs. const PANEL_COLLAPSED_WIDTH = 58 @@ -385,12 +399,6 @@ function scheduleCitationSourceScroll(tabId: string, sourceText: string): void { } } -function getNoticeTone(message: string): StatusToastInput['tone'] { - const errorPattern = - /\b(failed|could not|unexpected|error|select|open a page|create a collection|already captured|already in)\b/i - return errorPattern.test(message) ? 'error' : 'success' -} - function getErrorMessage(error: unknown): string { if (typeof error === 'string') return error if (!(error instanceof Error)) return 'ÆTHER hit an unexpected error.' @@ -441,10 +449,16 @@ function App(): React.JSX.Element { const [settings, setSettings] = useState({ browser: { defaultSearchEngine: 'google' }, developerMode: false, - updates: { autoCheck: true } + updates: { autoCheck: true }, + appearance: 'system' }) const [updateCheck, setUpdateCheck] = useState(null) const [updateChecking, setUpdateChecking] = useState(false) + const [updateInstalling, setUpdateInstalling] = useState(false) + const [updateProgress, setUpdateProgress] = useState(null) + const [updateInstalled, setUpdateInstalled] = useState(null) + const [diagnostics, setDiagnostics] = useState(null) + const [exportingDiagnostics, setExportingDiagnostics] = useState(false) const [panelWidth, setPanelWidth] = useState(readStoredPanelWidth) const [panelResizing, setPanelResizing] = useState(false) // Pointer handlers read the committed width synchronously mid-drag. @@ -525,6 +539,51 @@ function App(): React.JSX.Element { const findInputRef = useRef(null) const toastIdRef = useRef(0) + const showToast = useCallback((input: StatusToastInput): void => { + if (!input.message || /^starting\s+æ?ther$/i.test(input.message)) return + + const id = toastIdRef.current + 1 + toastIdRef.current = id + setToast({ ...input, id }) + if (input.durationMs !== 0) { + window.setTimeout(() => { + setToast((current) => (current?.id === id ? null : current)) + }, input.durationMs ?? 3600) + } + window.aether.layout.showStatusToast(input).catch(() => undefined) + }, []) + + // One place where a user-facing outcome is announced: the footer keeps the text as + // ambient status, the toast surfaces it regardless of whether the panel is open. + // Tone is passed in rather than inferred from the wording — guessing it from the + // message meant a phrasing change silently altered how the result was presented. + const report = useCallback( + (message: string, tone: StatusToastInput['tone']): void => { + setNotice(message) + // Failures get longer on screen: they are the messages worth actually reading, + // and they tend to be the longest. + showToast({ message, tone, durationMs: tone === 'error' ? 7000 : undefined }) + }, + [showToast] + ) + + const reportError = useCallback( + (error: unknown): void => report(getErrorMessage(error), 'error'), + [report] + ) + + const reportSuccess = useCallback( + (message: string): void => report(message, 'success'), + [report] + ) + + // An action the user asked for that could not start yet — not a failure, but it must + // be visible, otherwise the control simply appears to do nothing. + const reportBlocked = useCallback( + (message: string): void => report(message, 'info'), + [report] + ) + const activeTab = useMemo( () => tabs.find((tab) => tab.id === activeTabId) ?? tabs.find((tab) => tab.isActive) ?? tabs[0], [activeTabId, tabs] @@ -637,11 +696,11 @@ function App(): React.JSX.Element { const openFindBar = useCallback((): void => { if (dashboardOpen || isStartPage || !activeTab?.id) { - setNotice('Open a web page before using find.') + reportBlocked('Open a web page before using find.') return } setFindOpen(true) - }, [activeTab?.id, dashboardOpen, isStartPage]) + }, [activeTab?.id, dashboardOpen, isStartPage, reportBlocked]) const findHighlight = useCallback( (query: string, action: FindAction): void => { @@ -655,9 +714,9 @@ function App(): React.JSX.Element { } void window.aether.tabs .find(activeTab.id, trimmed, action) - .catch((error) => setNotice(getErrorMessage(error))) + .catch((error) => reportError(error)) }, - [activeTab?.id] + [activeTab?.id, reportError] ) const closeFindBar = useCallback((): void => { @@ -753,18 +812,18 @@ function App(): React.JSX.Element { } })) if (result.updateAvailable) { - setNotice(`ÆTHER ${result.latestVersion ?? result.latestName ?? 'update'} is available.`) + report(`ÆTHER ${result.latestVersion ?? result.latestName ?? 'update'} is available.`, 'info') } else if (!options?.quiet && result.error) { - setNotice(result.error) + report(result.error, 'error') } else if (!options?.quiet) { - setNotice('ÆTHER is up to date.') + reportSuccess('ÆTHER is up to date.') } } catch (error) { - if (!options?.quiet) setNotice(getErrorMessage(error)) + if (!options?.quiet) reportError(error) } finally { if (!options?.quiet) setUpdateChecking(false) } - }, []) + }, [report, reportError, reportSuccess]) const searchLibrary = useCallback( async (query: string, collectionId?: string): Promise => { @@ -773,12 +832,12 @@ function App(): React.JSX.Element { try { setSearchResult(await window.aether.search.library({ query, collectionId })) } catch (error) { - setNotice(getErrorMessage(error)) + reportError(error) } finally { setSearching(false) } }, - [] + [reportError] ) const clearSearch = useCallback((): void => setSearchResult(null), []) @@ -835,10 +894,10 @@ function App(): React.JSX.Element { setChatThread([]) setChatResult(null) } catch (error) { - setNotice(getErrorMessage(error)) + reportError(error) } })() - }, [activeThreadCollectionId]) + }, [activeThreadCollectionId, reportError]) // Switching hub switches thread, so a stored session reappears where it was left. useEffect(() => { @@ -863,14 +922,14 @@ function App(): React.JSX.Element { try { const result = await window.aether.capture.url({ collectionId, url }) await refreshCollections(collectionId) - setNotice(`Captured "${result.title}" into ${result.collectionName}.`) + reportSuccess(`Captured "${result.title}" into ${result.collectionName}.`) } catch (error) { - setNotice(getErrorMessage(error)) + reportError(error) } finally { setCapturingLink(false) } }, - [refreshCollections] + [refreshCollections, reportError, reportSuccess] ) const captureOpenTabs = useCallback( @@ -880,7 +939,7 @@ function App(): React.JSX.Element { .map((tab) => tab.url) .filter((url) => url && url !== START_PAGE_URL && !url.startsWith('aether://')) if (urls.length === 0) { - setNotice('No open tabs point at a web page yet.') + reportBlocked('No open tabs point at a web page yet.') return } @@ -889,38 +948,64 @@ function App(): React.JSX.Element { try { const result = await window.aether.capture.urls({ collectionId, urls }) await refreshCollections(collectionId) - const saved = `Captured ${result.captured.length} of ${urls.length} tabs into ${result.collectionName}.` - setNotice( + const saved = `Captured ${result.captured.length} of ${countLabel(urls.length, 'tab')} into ${result.collectionName}.` + // A partial capture is neither a clean success nor a failure. + report( result.failures.length > 0 ? `${saved} Skipped: ${result.failures.map((item) => item.reason).join(' ')}` - : saved + : saved, + result.failures.length > 0 ? 'info' : 'success' ) } catch (error) { - setNotice(getErrorMessage(error)) + reportError(error) } finally { setCapturingLink(false) } }, - [refreshCollections, tabs] + [refreshCollections, report, reportBlocked, reportError, tabs] ) + const refreshDiagnostics = useCallback(async (): Promise => { + try { + setDiagnostics(await window.aether.system.diagnostics()) + } catch { + // Not surfaced: a diagnostics panel that cannot load is not itself worth an + // error toast, and reporting it would need the very channel that just failed. + setDiagnostics([]) + } + }, []) + + const exportDiagnostics = useCallback(async (): Promise => { + setExportingDiagnostics(true) + try { + const result = await window.aether.system.exportDiagnostics() + reportSuccess( + `Diagnostics log (${formatByteSize(result.byteSize)}) saved to ${result.path}` + ) + } catch (error) { + reportError(error) + } finally { + setExportingDiagnostics(false) + } + }, [reportError, reportSuccess]) + const exportLibrary = useCallback(async (): Promise => { setExportingLibrary(true) setNotice(null) try { const result = await window.aether.system.exportLibrary() setLibraryExport(result) - setNotice( - `Exported ${result.captureCount} sources (${formatExportSize(result.byteSize)}) to ${ + reportSuccess( + `Exported ${countLabel(result.captureCount, 'source')} (${formatByteSize(result.byteSize)}) to ${ result.path }` ) } catch (error) { - setNotice(getErrorMessage(error)) + reportError(error) } finally { setExportingLibrary(false) } - }, []) + }, [reportError, reportSuccess]) // Asked for on open rather than at startup: answering it loads the whole vector // store, which is work launch should not pay for. @@ -939,18 +1024,19 @@ function App(): React.JSX.Element { setNotice(null) try { const result = await window.aether.system.reindexLibrary() - setNotice( + report( result.stillPending > 0 ? `Re-indexed ${result.embedded} passages at ${result.dim} dims — ${result.stillPending} could not be embedded.` - : `Re-indexed ${result.embedded} passages at ${result.dim} dims.` + : `Re-indexed ${result.embedded} passages at ${result.dim} dims.`, + result.stillPending > 0 ? 'info' : 'success' ) await refreshIndexStatus() } catch (error) { - setNotice(getErrorMessage(error)) + reportError(error) } finally { setReindexing(false) } - }, [refreshIndexStatus]) + }, [refreshIndexStatus, report, reportError]) useEffect(() => { if (!settings.updates.autoCheck || autoUpdateCheckStartedRef.current) return undefined @@ -974,11 +1060,11 @@ function App(): React.JSX.Element { await refreshShell() return tab } catch (error) { - setNotice(getErrorMessage(error)) + reportError(error) return null } }, - [refreshShell] + [refreshShell, reportError] ) useEffect(() => { @@ -1092,7 +1178,7 @@ function App(): React.JSX.Element { return case 'capture-page': if (dashboardOpen || startPageActive) { - setNotice('Open a web page before capturing.') + reportBlocked('Open a web page before capturing.') return } await capturePage() @@ -1134,28 +1220,6 @@ function App(): React.JSX.Element { }) }) - const showToast = useCallback((input: StatusToastInput): void => { - if (!input.message || /^starting\s+æ?ther$/i.test(input.message)) return - - const id = toastIdRef.current + 1 - toastIdRef.current = id - setToast({ ...input, id }) - if (input.durationMs !== 0) { - window.setTimeout(() => { - setToast((current) => (current?.id === id ? null : current)) - }, input.durationMs ?? 3600) - } - window.aether.layout.showStatusToast(input).catch(() => undefined) - }, []) - - useEffect(() => { - if (!notice) return - showToast({ - message: notice, - tone: getNoticeTone(notice) - }) - }, [notice, showToast]) - useEffect(() => { const unsubscribe = window.aether.events.onCaptureProgress((progress: CaptureProgress) => { const suffix = @@ -1209,6 +1273,26 @@ function App(): React.JSX.Element { return unsubscribe }, []) + useEffect(() => { + const unsubscribe = window.aether.events.onUpdateProgress((progress) => { + setUpdateProgress(progress.done ? null : progress) + }) + + return unsubscribe + }, []) + + // The stylesheet reads data-theme off the root element. 'system' removes the + // attribute entirely rather than writing 'system', so prefers-color-scheme is + // what decides — an unrecognised value would leave the app stuck in light. + useEffect(() => { + const root = document.documentElement + if (settings.appearance === 'system') { + root.removeAttribute('data-theme') + } else { + root.setAttribute('data-theme', settings.appearance) + } + }, [settings.appearance]) + useEffect(() => { if (!modelSetupVisible) return void window.aether.layout.setModalOverlayOpen(true).catch(() => undefined) @@ -1264,14 +1348,14 @@ function App(): React.JSX.Element { }) setStatus(nextStatus) setModelSetupComplete(true) - setNotice('AiON local models installed.') + reportSuccess('AiON local models installed.') window.setTimeout(() => { void closeModelSetup() }, 900) } catch (error) { const message = getErrorMessage(error) setModelSetupError(message) - setNotice(message) + report(message, 'error') } finally { setBusy(null) } @@ -1283,6 +1367,15 @@ function App(): React.JSX.Element { setDashboardOpen(true) } + // Sends an iCE topic into the dashboard's library search. The counterpart to + // openCrystallizedTopic, which sends it out to the web instead. + async function openTopicInLibrary(query: string): Promise { + await window.aether.dashboard.open() + setWorkspaceMode('dashboard') + setDashboardOpen(true) + await searchLibrary(query) + } + async function openCrystallizer(): Promise { await window.aether.dashboard.open() setWorkspaceMode('crystallizer') @@ -1327,7 +1420,7 @@ function App(): React.JSX.Element { await window.aether.tabs.close(tabId) await refreshShell() } catch (error) { - setNotice(getErrorMessage(error)) + reportError(error) } } @@ -1346,7 +1439,7 @@ function App(): React.JSX.Element { setDashboardOpen(false) await refreshShell() } catch (error) { - setNotice(getErrorMessage(error)) + reportError(error) } } @@ -1364,7 +1457,7 @@ function App(): React.JSX.Element { setDashboardOpen(false) await refreshShell() } catch (error) { - setNotice(getErrorMessage(error)) + reportError(error) } } @@ -1391,7 +1484,7 @@ function App(): React.JSX.Element { setDashboardOpen(false) addressInputRef.current?.blur() } catch (error) { - setNotice(getErrorMessage(error)) + reportError(error) } } @@ -1409,7 +1502,7 @@ function App(): React.JSX.Element { await createTab({ url: target }) } } catch (error) { - setNotice(getErrorMessage(error)) + reportError(error) } } @@ -1424,7 +1517,7 @@ function App(): React.JSX.Element { await window.aether.tabs.navigate(activeTab.id, target) setDashboardOpen(false) } catch (error) { - setNotice(getErrorMessage(error)) + reportError(error) } } @@ -1436,7 +1529,7 @@ function App(): React.JSX.Element { await window.aether.tabs.goBack(activeTab.id) await refreshShell() } catch (error) { - setNotice(getErrorMessage(error)) + reportError(error) } } @@ -1448,7 +1541,7 @@ function App(): React.JSX.Element { await window.aether.tabs.goForward(activeTab.id) await refreshShell() } catch (error) { - setNotice(getErrorMessage(error)) + reportError(error) } } @@ -1460,7 +1553,7 @@ function App(): React.JSX.Element { if (!collectionDialog || collectionDialog.mode === 'delete') return await runTask( - collectionDialog.mode === 'create' ? 'Creating collection' : 'Updating collection', + collectionDialog.mode === 'create' ? 'Creating hub' : 'Updating hub', async () => { const collection = collectionDialog.mode === 'create' @@ -1474,7 +1567,7 @@ function App(): React.JSX.Element { await closeCollectionDialog() await refreshCollections(collection.id) setFlowGraphResult(null) - setNotice(`${collection.name} is ready.`) + reportSuccess(`${collection.name} is ready.`) } ) } @@ -1482,7 +1575,7 @@ function App(): React.JSX.Element { async function confirmDeleteCollection(): Promise { if (!collectionDialog || collectionDialog.mode !== 'delete') return - await runTask('Deleting collection', async () => { + await runTask('Deleting hub', async () => { await window.aether.collections.delete(collectionDialog.collection.id) await closeCollectionDialog() setChatResult(null) @@ -1490,7 +1583,7 @@ function App(): React.JSX.Element { setFlowGraphResult(null) await refreshCollections() setStatus(await window.aether.system.status()) - setNotice('Collection deleted.') + reportSuccess('Hub deleted.') }) } @@ -1560,7 +1653,7 @@ function App(): React.JSX.Element { async function capturePage(): Promise { if (!selectedCollection) { await openCollectionDialog({ mode: 'create' }) - setNotice('Create a collection before capturing.') + reportBlocked('Create a hub before capturing.') return } @@ -1578,7 +1671,7 @@ function App(): React.JSX.Element { setAskCollectionId(result.collectionId) setSemanticTrailResult(null) setFlowGraphResult(null) - setNotice(`Saved ${result.chunkCount} chunks into ${result.collectionName}.`) + reportSuccess(`Saved ${countLabel(result.chunkCount, 'chunk')} into ${result.collectionName}.`) }) } @@ -1588,7 +1681,7 @@ function App(): React.JSX.Element { await refreshCollections(selectedCollection?.id) setSemanticTrailResult(null) setFlowGraphResult(null) - setNotice('Capture deleted.') + reportSuccess('Capture deleted.') }) } @@ -1599,7 +1692,7 @@ function App(): React.JSX.Element { setChatResult(null) setSemanticTrailResult(null) setFlowGraphResult(null) - setNotice(`Moved ${capture.title}.`) + reportSuccess(`Moved ${capture.title}.`) }) } @@ -1630,12 +1723,12 @@ function App(): React.JSX.Element { setPanelCollapsed(false) await window.aether.layout.setIntelligencePanelCollapsed(false) setChatPrompt(prompt) - setNotice('Select a knowledge hub or include the current page before asking ÆTHER.') + reportBlocked('Select a knowledge hub or include the current page before asking ÆTHER.') return } if (includeCurrentPage && !canUseCurrentPage) { - setNotice('Open a page before asking with current-page context.') + reportBlocked('Open a page before asking with current-page context.') return } @@ -1697,11 +1790,11 @@ function App(): React.JSX.Element { async function saveActiveTabToHub(): Promise { if (!activeTab?.url) { - setNotice('Open a page before saving it to the Hub.') + reportBlocked('Open a page before saving it to the Hub.') return } if (activeTabSavedToHub && !activeTabHubNeedsMetadata) { - setNotice('This page is already saved as a portal.') + reportBlocked('This page is already saved as a portal.') return } @@ -1714,7 +1807,7 @@ function App(): React.JSX.Element { themeColor: activeTab.themeColor }) await refreshShortcuts() - setNotice(updatingPortal ? 'Updated portal appearance.' : 'Saved to Hub.') + reportSuccess(updatingPortal ? 'Updated portal appearance.' : 'Saved to Hub.') }) } @@ -1798,7 +1891,7 @@ function App(): React.JSX.Element { setSemanticTrailResult(result) } catch (error) { if (semanticTrailRequestRef.current === requestId) { - setNotice(getErrorMessage(error)) + reportError(error) } } finally { if (semanticTrailRequestRef.current === requestId) { @@ -1806,7 +1899,7 @@ function App(): React.JSX.Element { } } }, - [canUseCurrentPage, dashboardOpen, semanticTrailQuery, status?.embeddingModel] + [canUseCurrentPage, dashboardOpen, reportError, semanticTrailQuery, status?.embeddingModel] ) async function buildFlowGraph(query = flowGraphQuery): Promise { @@ -1923,7 +2016,7 @@ function App(): React.JSX.Element { const result = await window.aether.air.render(buildAirInput()) setAirResult(result) await refreshAirRecent() - setNotice(`Rendered ${result.filename}.`) + reportSuccess(`Rendered ${result.filename}.`) }) } @@ -1959,15 +2052,15 @@ function App(): React.JSX.Element { try { const result = await window.aether.crystallizer.generate({ keyword }) - setNotice( - `Mapped ${result.items.length} fragments with ${ + reportSuccess( + `Mapped ${countLabel(result.items.length, 'topic')} with ${ formatVisibleModelName(result.model) ?? result.model }.` ) return result } catch (error) { const message = error instanceof Error ? getErrorMessage(error) : 'Crystallization failed.' - setNotice(message) + report(message, 'error') throw error } finally { setBusy(null) @@ -1982,11 +2075,11 @@ function App(): React.JSX.Element { const saved = await window.aether.crystallizer.save(input) setActiveSavedIceberg(saved) await refreshSavedIcebergs() - setNotice(`Crystallized ${saved.title}.`) + reportSuccess(`Crystallized ${saved.title}.`) return saved } catch (error) { const message = error instanceof Error ? getErrorMessage(error) : 'Saving iceberg failed.' - setNotice(message) + report(message, 'error') throw error } finally { setBusy(null) @@ -2003,12 +2096,12 @@ function App(): React.JSX.Element { setActiveSavedIceberg(saved) setWorkspaceMode('crystallizer') setDashboardOpen(true) - setNotice(`Opened ${saved.title}.`) + reportSuccess(`Opened ${saved.title}.`) return saved } catch (error) { const message = error instanceof Error ? getErrorMessage(error) : 'Could not open saved iceberg.' - setNotice(message) + report(message, 'error') throw error } finally { setBusy(null) @@ -2025,11 +2118,11 @@ function App(): React.JSX.Element { setActiveSavedIceberg(null) } await refreshSavedIcebergs() - setNotice('Saved iceberg deleted.') + reportSuccess('Saved iceberg deleted.') } catch (error) { const message = error instanceof Error ? getErrorMessage(error) : 'Could not delete saved iceberg.' - setNotice(message) + report(message, 'error') throw error } finally { setBusy(null) @@ -2077,8 +2170,10 @@ function App(): React.JSX.Element { async function openSettings(): Promise { setSettingsOpen(true) - // Not awaited: this loads the vector store, and the panel should open immediately. + // Neither is awaited: the index status loads the vector store, and the panel + // should open immediately. void refreshIndexStatus() + void refreshDiagnostics() await window.aether.layout.setModalOverlayOpen(true) } @@ -2093,7 +2188,7 @@ function App(): React.JSX.Element { browser: { defaultSearchEngine } }) setSettings(nextSettings) - setNotice('Default search engine updated.') + reportSuccess('Default search engine updated.') }) } @@ -2101,7 +2196,17 @@ function App(): React.JSX.Element { await runTask('Updating settings', async () => { const nextSettings = await window.aether.system.updateSettings({ developerMode }) setSettings(nextSettings) - setNotice(developerMode ? 'Developer Mode enabled.' : 'Developer Mode disabled.') + reportSuccess(developerMode ? 'Developer Mode enabled.' : 'Developer Mode disabled.') + }) + } + + async function updateAppearance(appearance: Appearance): Promise { + // Applied optimistically: the effect above repaints on the next frame, and + // waiting for the disk write would make a theme click feel broken. + setSettings((current) => ({ ...current, appearance })) + await runTask('Updating settings', async () => { + const nextSettings = await window.aether.system.updateSettings({ appearance }) + setSettings(nextSettings) }) } @@ -2111,7 +2216,7 @@ function App(): React.JSX.Element { updates: { autoCheck } }) setSettings(nextSettings) - setNotice(autoCheck ? 'Update checks enabled.' : 'Update checks disabled.') + reportSuccess(autoCheck ? 'Update checks enabled.' : 'Update checks disabled.') }) } @@ -2120,7 +2225,39 @@ function App(): React.JSX.Element { try { await window.aether.system.openExternalUrl(updateCheck.releaseUrl) } catch (error) { - setNotice(getErrorMessage(error)) + reportError(error) + } + } + + async function installUpdate(): Promise { + setUpdateInstalling(true) + setUpdateProgress(null) + try { + const result = await window.aether.system.installUpdate() + if (result.status === 'installed') { + setUpdateInstalled(result) + reportSuccess(result.message) + } else { + // The three non-install outcomes are all things the user has to act on + // themselves, so they read as blocked rather than as failures. + setUpdateInstalled(null) + reportBlocked(result.message) + } + } catch (error) { + setUpdateInstalled(null) + reportError(error) + } finally { + setUpdateInstalling(false) + setUpdateProgress(null) + } + } + + async function relaunchForUpdate(): Promise { + try { + // Never resolves when it works — the process is replaced. + await window.aether.system.relaunch() + } catch (error) { + reportError(error) } } @@ -2131,7 +2268,7 @@ function App(): React.JSX.Element { await runTask('Updating local models', async () => { const nextStatus = await window.aether.system.updateModels(input) setStatus(nextStatus) - setNotice('Local model selection updated.') + reportSuccess('Local model selection updated.') }) } @@ -2205,7 +2342,7 @@ function App(): React.JSX.Element { try { await task() } catch (error) { - setNotice(getErrorMessage(error)) + reportError(error) } finally { setBusy(null) } @@ -2248,6 +2385,7 @@ function App(): React.JSX.Element { onGenerate={generateIceberg} onOpenSaved={openSavedIceberg} onOpenTopic={openCrystallizedTopic} + onOpenInLibrary={openTopicInLibrary} onSave={saveIceberg} /> ) @@ -2338,12 +2476,21 @@ function App(): React.JSX.Element { settings={settings} updateCheck={updateCheck} updateChecking={updateChecking} + updateInstalling={updateInstalling} + updateProgress={updateProgress} + updateInstalled={updateInstalled} + diagnostics={diagnostics} + exportingDiagnostics={exportingDiagnostics} + onExportDiagnostics={exportDiagnostics} + onInstallUpdate={installUpdate} + onRelaunchForUpdate={relaunchForUpdate} onClose={closeSettings} onDefaultSearchEngineChange={updateDefaultSearchEngine} onDeveloperModeChange={updateDeveloperMode} onExportLibrary={exportLibrary} onCheckForUpdates={() => checkForUpdates()} onOpenUpdateRelease={openUpdateRelease} + onAppearanceChange={updateAppearance} onUpdateAutoCheck={updateAutoCheck} onOpenModelSetup={openModelSetup} /> @@ -2835,13 +2982,6 @@ function FindBar({ ) } -function formatExportSize(bytes: number): string { - if (bytes >= 1_000_000_000) return `${(bytes / 1_000_000_000).toFixed(1)} GB` - if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB` - if (bytes >= 1_000) return `${Math.round(bytes / 1_000)} KB` - return `${bytes} B` -} - function formatSettingsDate(value?: string): string { if (!value) return 'Never' const date = new Date(value) @@ -2871,12 +3011,21 @@ function SettingsModal({ settings, updateCheck, updateChecking, + updateInstalling, + updateProgress, + updateInstalled, + diagnostics, + exportingDiagnostics, + onExportDiagnostics, + onInstallUpdate, + onRelaunchForUpdate, onClose, onCheckForUpdates, onDefaultSearchEngineChange, onDeveloperModeChange, onExportLibrary, onOpenUpdateRelease, + onAppearanceChange, onUpdateAutoCheck, onOpenModelSetup }: { @@ -2889,22 +3038,32 @@ function SettingsModal({ settings: AppSettings updateCheck: UpdateCheckResult | null updateChecking: boolean + updateInstalling: boolean + updateProgress: UpdateInstallProgress | null + updateInstalled: UpdateInstallResult | null + diagnostics: DiagnosticEntry[] | null + exportingDiagnostics: boolean + onExportDiagnostics: () => Promise + onInstallUpdate: () => Promise + onRelaunchForUpdate: () => Promise onClose: () => Promise onCheckForUpdates: () => Promise onDefaultSearchEngineChange: (value: SearchEngineId) => Promise onDeveloperModeChange: (value: boolean) => Promise onExportLibrary: () => Promise onOpenUpdateRelease: () => Promise + onAppearanceChange: (value: Appearance) => Promise onUpdateAutoCheck: (value: boolean) => Promise onOpenModelSetup: () => Promise }): React.JSX.Element { - // Suppressed while a library export or re-index is running, so a stray click or - // Escape cannot close the panel out from under work the user is watching. + // Suppressed while a library export, re-index, or update install is running, so + // a stray click or Escape cannot close the panel out from under work the user is + // watching. The update matters most: it is the one that replaces the app binary. const settingsBackdrop = useDismissableOverlay( () => { void onClose() }, - !exportingLibrary && !reindexing + !exportingLibrary && !reindexing && !updateInstalling ) const installedVersion = updateCheck?.currentVersion ?? APP_VERSION const searchEngines: Array<{ id: SearchEngineId; name: string; description: string }> = [ @@ -2989,6 +3148,39 @@ function SettingsModal({
+
+
+ +
+ +

+ System follows your desktop; the other two override it either way. +

+
+
+
+ {APPEARANCE_OPTIONS.map((option) => ( + + ))} +
+
+
+
+
+ + + Diagnostics + + {diagnostics === null + ? 'Loading…' + : diagnostics.length === 0 + ? 'No problems recorded. Kept on this machine only; nothing is sent anywhere.' + : `${countLabel(diagnostics.length, 'entry', 'entries')} recorded. Kept on this machine only; nothing is sent anywhere.`} + + +
+ +
+ + {diagnostics !== null && diagnostics.length > 0 && ( +
+ {/* Newest first, and capped: this is a glance at what went wrong, not + a log viewer. The full history goes out through Export Log. */} + {diagnostics.slice(0, 12).map((entry, index) => ( +
+ + {formatSettingsDate(entry.at)} + + {entry.level} + {entry.message} +
+ ))} +
+ )} + {indexStatus && indexStatus.pendingReembed > 0 && (
@@ -3114,6 +3351,35 @@ function SettingsModal({

{updateCheck.releaseNotes}

)} + {updateInstalling && ( +
+ + {formatUpdateProgress(updateProgress)} +
+ )} + + {updateInstalled &&

{updateInstalled.message}

} +
diff --git a/src/renderer/src/assets/styles/air-view.css b/src/renderer/src/assets/styles/air-view.css index 7657b60..9be9a24 100644 --- a/src/renderer/src/assets/styles/air-view.css +++ b/src/renderer/src/assets/styles/air-view.css @@ -7,10 +7,10 @@ overflow: hidden; padding: 20px 24px 24px; background: - radial-gradient(circle at 16% 8%, rgba(255, 255, 255, 0.96), transparent 20%), - radial-gradient(circle at 82% 14%, rgba(162, 224, 255, 0.24), transparent 26%), - radial-gradient(circle at 48% 92%, rgba(116, 216, 198, 0.14), transparent 34%), - linear-gradient(180deg, rgba(250, 254, 255, 0.8), rgba(235, 247, 252, 0.58)); + radial-gradient(circle at 16% 8%, rgb(var(--surface-rgb) / 0.96), transparent 20%), + radial-gradient(circle at 82% 14%, rgb(var(--sky-mist-rgb) / 0.24), transparent 26%), + radial-gradient(circle at 48% 92%, rgb(var(--aurora-rgb) / 0.14), transparent 34%), + linear-gradient(180deg, rgb(var(--surface-tint-warm-rgb) / 0.8), rgb(var(--surface-tint-rgb) / 0.58)); } .air-view::before { @@ -20,10 +20,10 @@ content: ''; opacity: 0.58; background-image: - linear-gradient(rgba(66, 133, 172, 0.07) 1px, transparent 1px), - linear-gradient(90deg, rgba(66, 133, 172, 0.06) 1px, transparent 1px); + linear-gradient(rgb(var(--azure-rgb) / 0.07) 1px, transparent 1px), + linear-gradient(90deg, rgb(var(--azure-rgb) / 0.06) 1px, transparent 1px); background-size: 34px 34px; - mask-image: linear-gradient(180deg, #000 0%, transparent 82%); + mask-image: linear-gradient(180deg, rgb(var(--mask-rgb)) 0%, transparent 82%); } .air-hero { @@ -50,15 +50,15 @@ place-items: center; width: 48px; height: 48px; - border: 1px solid rgba(84, 143, 184, 0.22); + border: 1px solid rgb(var(--line-strong-rgb) / 0.22); border-radius: 8px; background: - radial-gradient(circle at 30% 20%, rgba(255, 255, 255, 0.98), transparent 44%), - linear-gradient(145deg, rgba(235, 250, 255, 0.96), rgba(118, 202, 226, 0.62)); - color: #226f96; + radial-gradient(circle at 30% 20%, rgb(var(--surface-rgb) / 0.98), transparent 44%), + linear-gradient(145deg, rgb(var(--surface-tint-rgb) / 0.96), rgb(var(--sky-soft-rgb) / 0.62)); + color: rgb(var(--ocean-rgb)); box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.82), - 0 16px 32px rgba(64, 139, 172, 0.14); + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.82), + 0 16px 32px rgb(var(--shadow-soft-rgb) / 0.14); } .air-mark svg { @@ -68,7 +68,7 @@ .air-identity h1 { margin: 0; - color: #123955; + color: rgb(var(--ink-teal-rgb)); font-size: 30px; font-weight: 880; line-height: 1; @@ -76,7 +76,7 @@ .air-identity p { margin: 5px 0 0; - color: #52728d; + color: rgb(var(--slate-ocean-rgb)); font-size: 12px; font-weight: 760; } @@ -85,11 +85,11 @@ min-width: 0; max-width: 48%; overflow: hidden; - border: 1px solid rgba(84, 143, 184, 0.16); + border: 1px solid rgb(var(--line-strong-rgb) / 0.16); border-radius: 8px; padding: 8px 11px; - background: rgba(255, 255, 255, 0.64); - color: #4f718c; + background: rgb(var(--surface-rgb) / 0.64); + color: rgb(var(--slate-ocean-rgb)); font-size: 11px; font-weight: 740; text-overflow: ellipsis; @@ -109,14 +109,14 @@ .air-panel { min-width: 0; min-height: 0; - border: 1px solid rgba(87, 150, 184, 0.18); + border: 1px solid rgb(var(--line-strong-rgb) / 0.18); border-radius: 8px; background: - radial-gradient(circle at 18% 14%, rgba(255, 255, 255, 0.92), transparent 42%), - rgba(255, 255, 255, 0.62); + radial-gradient(circle at 18% 14%, rgb(var(--surface-rgb) / 0.92), transparent 42%), + rgb(var(--surface-rgb) / 0.62); box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.72), - 0 18px 34px rgba(58, 112, 152, 0.09); + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.72), + 0 18px 34px rgb(var(--ocean-rgb) / 0.09); } .air-lens-panel, @@ -132,7 +132,7 @@ .air-lens-form label, .air-section-heading h2, .air-actions-panel h2 { - color: #143b55; + color: rgb(var(--ink-teal-rgb)); font-size: 13px; font-weight: 860; } @@ -143,16 +143,16 @@ align-items: center; gap: 8px; height: 44px; - border: 1px solid rgba(93, 154, 190, 0.2); + border: 1px solid rgb(var(--steel-soft-rgb) / 0.2); border-radius: 8px; padding: 0 7px 0 12px; background: - radial-gradient(circle at 14% 24%, rgba(255, 255, 255, 0.96), transparent 42%), - linear-gradient(145deg, rgba(255, 255, 255, 0.78), rgba(219, 245, 255, 0.58)); + radial-gradient(circle at 14% 24%, rgb(var(--surface-rgb) / 0.96), transparent 42%), + linear-gradient(145deg, rgb(var(--surface-rgb) / 0.78), rgb(var(--surface-sky-rgb) / 0.58)); } .air-search-row svg { - color: #3d8fab; + color: rgb(var(--line-strong-rgb)); } .air-search-row input, @@ -162,7 +162,7 @@ border: 0; padding: 0; background: transparent; - color: #173f5a; + color: rgb(var(--ink-teal-rgb)); font-size: 13px; font-weight: 720; box-shadow: none; @@ -208,8 +208,8 @@ align-items: center; border-radius: 8px; padding: 0 10px; - background: rgba(255, 255, 255, 0.58); - color: #426985; + background: rgb(var(--surface-rgb) / 0.58); + color: rgb(var(--text-ocean-rgb)); font-size: 11px; font-weight: 820; } @@ -218,9 +218,9 @@ .air-lens-buttons button:hover { border-color: rgba(42, 141, 172, 0.3); background: - radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.96), transparent 42%), - rgba(229, 250, 255, 0.72); - color: #226f96; + radial-gradient(circle at 18% 18%, rgb(var(--surface-rgb) / 0.96), transparent 42%), + rgb(var(--surface-tint-cool-rgb) / 0.72); + color: rgb(var(--ocean-rgb)); } .air-lens-buttons svg, @@ -242,7 +242,7 @@ flex-wrap: wrap; gap: 8px; margin-top: 10px; - color: #5e7d94; + color: rgb(var(--slate-blue-rgb)); font-size: 11px; font-weight: 760; } @@ -251,10 +251,10 @@ min-width: 0; max-width: 100%; overflow: hidden; - border: 1px solid rgba(69, 145, 174, 0.14); + border: 1px solid rgb(var(--line-strong-rgb) / 0.14); border-radius: 999px; padding: 4px 8px; - background: rgba(234, 251, 255, 0.65); + background: rgb(var(--surface-tint-rgb) / 0.65); text-overflow: ellipsis; white-space: nowrap; } @@ -269,7 +269,7 @@ .air-actions-panel p, .air-section-heading p { margin: 4px 0 0; - color: #5c7c93; + color: rgb(var(--slate-blue-rgb)); font-size: 12px; font-weight: 650; } @@ -318,7 +318,7 @@ .air-section-heading > span, .air-section-heading > svg { flex: 0 0 auto; - color: #5e7d94; + color: rgb(var(--slate-blue-rgb)); font-size: 11px; font-weight: 760; } @@ -352,10 +352,10 @@ min-width: 0; grid-template-columns: 34px minmax(0, 1fr); gap: 9px; - border: 1px solid rgba(80, 151, 183, 0.16); + border: 1px solid rgb(var(--line-strong-rgb) / 0.16); border-radius: 8px; padding: 9px; - background: rgba(255, 255, 255, 0.62); + background: rgb(var(--surface-rgb) / 0.62); } .air-source-row > span { @@ -363,10 +363,10 @@ place-items: center; width: 32px; height: 32px; - border: 1px solid rgba(43, 146, 112, 0.2); + border: 1px solid rgb(var(--success-rgb) / 0.2); border-radius: 999px; - background: rgba(225, 249, 242, 0.7); - color: #2b9270; + background: rgb(var(--surface-mint-rgb) / 0.7); + color: rgb(var(--success-rgb)); font-size: 13px; font-weight: 880; } @@ -380,7 +380,7 @@ .air-history-row strong { display: block; overflow: hidden; - color: #143b55; + color: rgb(var(--ink-teal-rgb)); font-size: 13px; font-weight: 860; text-overflow: ellipsis; @@ -391,7 +391,7 @@ display: -webkit-box; margin: 4px 0; overflow: hidden; - color: #416177; + color: rgb(var(--slate-steel-rgb)); font-size: 12px; font-weight: 620; line-height: 1.4; @@ -405,7 +405,7 @@ .air-history-row span { display: block; overflow: hidden; - color: #638199; + color: rgb(var(--slate-blue-rgb)); font-size: 10px; font-weight: 780; text-overflow: ellipsis; @@ -414,13 +414,13 @@ .air-markdown-preview { margin: 0; - border: 1px solid rgba(80, 151, 183, 0.16); + border: 1px solid rgb(var(--line-strong-rgb) / 0.16); border-radius: 8px; padding: 14px; background: - linear-gradient(180deg, rgba(255, 255, 255, 0.82), rgba(246, 251, 253, 0.72)), - rgba(255, 255, 255, 0.7); - color: #243b55; + linear-gradient(180deg, rgb(var(--surface-rgb) / 0.82), rgb(var(--surface-tint-warm-rgb) / 0.72)), + rgb(var(--surface-rgb) / 0.7); + color: rgb(var(--ink-slate-rgb)); font-family: 'SFMono-Regular', 'SF Mono', Consolas, monospace; font-size: 11px; line-height: 1.55; @@ -434,10 +434,10 @@ grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: center; - border: 1px solid rgba(80, 151, 183, 0.16); + border: 1px solid rgb(var(--line-strong-rgb) / 0.16); border-radius: 8px; padding: 10px; - background: rgba(255, 255, 255, 0.62); + background: rgb(var(--surface-rgb) / 0.62); } .air-history-row > div:last-child { @@ -450,8 +450,8 @@ width: 30px; height: 30px; padding: 0; - background: rgba(255, 255, 255, 0.66); - color: #377188; + background: rgb(var(--surface-rgb) / 0.66); + color: rgb(var(--text-ocean-rgb)); } .air-empty-state { @@ -459,7 +459,7 @@ min-height: 160px; place-content: center; padding: 20px; - color: #5c7c93; + color: rgb(var(--slate-blue-rgb)); font-size: 13px; font-weight: 680; line-height: 1.45; diff --git a/src/renderer/src/assets/styles/browser-chrome.css b/src/renderer/src/assets/styles/browser-chrome.css index 04966d8..d4d03b9 100644 --- a/src/renderer/src/assets/styles/browser-chrome.css +++ b/src/renderer/src/assets/styles/browser-chrome.css @@ -7,9 +7,9 @@ display: grid; grid-template-rows: 42px 32px 42px; height: var(--nav-height); - border-bottom: 1px solid rgba(143, 165, 196, 0.22); - background: rgba(255, 255, 255, 0.58); - box-shadow: 0 14px 32px rgba(71, 104, 146, 0.08); + border-bottom: 1px solid rgb(var(--slate-rgb) / 0.22); + background: rgb(var(--surface-rgb) / 0.58); + box-shadow: 0 14px 32px rgb(var(--slate-ocean-rgb) / 0.08); backdrop-filter: blur(24px) saturate(1.35); } @@ -67,9 +67,9 @@ overflow-x: auto; overflow-y: hidden; background: - linear-gradient(180deg, rgba(255, 255, 255, 0.18), rgba(237, 246, 255, 0.2)), - rgba(255, 255, 255, 0.1); - scrollbar-color: rgba(82, 178, 219, 0.42) transparent; + linear-gradient(180deg, rgb(var(--surface-rgb) / 0.18), rgb(var(--surface-tint-rgb) / 0.2)), + rgb(var(--surface-rgb) / 0.1); + scrollbar-color: rgb(var(--accent-bright-rgb) / 0.42) transparent; scrollbar-width: thin; scroll-padding: 14px; } @@ -89,8 +89,8 @@ .tab-strip::-webkit-scrollbar-thumb { border-radius: 999px; background: - linear-gradient(90deg, rgba(255, 255, 255, 0.72), rgba(255, 255, 255, 0.24)), - rgba(82, 178, 219, 0.42); + linear-gradient(90deg, rgb(var(--surface-rgb) / 0.72), rgb(var(--surface-rgb) / 0.24)), + rgb(var(--accent-bright-rgb) / 0.42); } .tab-chip, @@ -110,15 +110,15 @@ width: auto; min-width: 92px; max-width: var(--tab-max-width); - border-color: color-mix(in srgb, var(--tab-tint, #6d97c8) 28%, rgba(120, 150, 190, 0.28)); + border-color: color-mix(in srgb, var(--tab-tint, rgb(var(--steel-soft-rgb))) 28%, rgb(var(--line-rgb) / 0.28)); padding: 0 5px; background: - linear-gradient(180deg, rgba(255, 255, 255, 0.78), rgba(255, 255, 255, 0.46)), - color-mix(in srgb, var(--tab-tint, #6d97c8) 18%, transparent); - color: color-mix(in srgb, var(--tab-tint, #557ca8) 34%, #2f4159); + linear-gradient(180deg, rgb(var(--surface-rgb) / 0.78), rgb(var(--surface-rgb) / 0.46)), + color-mix(in srgb, var(--tab-tint, rgb(var(--steel-soft-rgb))) 18%, transparent); + color: color-mix(in srgb, var(--tab-tint, rgb(var(--steel-deep-rgb))) 34%, rgb(var(--slate-darkest-rgb))); text-align: left; box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.82), + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.82), 0 7px 18px rgba(52, 86, 132, 0.1); transform-origin: center bottom; transition: @@ -153,21 +153,21 @@ .tab-chip.active, .tab-chip:hover { - border-color: color-mix(in srgb, var(--tab-tint, #5a8fd0) 45%, rgba(86, 139, 218, 0.3)); + border-color: color-mix(in srgb, var(--tab-tint, rgb(var(--steel-rgb))) 45%, rgb(var(--steel-rgb) / 0.3)); background: - linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(255, 255, 255, 0.68)), - color-mix(in srgb, var(--tab-tint, #6d97c8) 26%, transparent); + linear-gradient(180deg, rgb(var(--surface-rgb) / 0.92), rgb(var(--surface-rgb) / 0.68)), + color-mix(in srgb, var(--tab-tint, rgb(var(--steel-soft-rgb))) 26%, transparent); color: var(--ink); box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.9), - 0 10px 24px color-mix(in srgb, var(--tab-tint, #6d97c8) 22%, rgba(84, 130, 199, 0.12)); + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.9), + 0 10px 24px color-mix(in srgb, var(--tab-tint, #6d97c8) 22%, rgb(var(--steel-vivid-rgb) / 0.12)); transform: translateY(-1px) scale(1.012); } .tab-chip.active { background: - linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(255, 255, 255, 0.74)), - color-mix(in srgb, var(--tab-tint, #6d97c8) 34%, transparent); + linear-gradient(180deg, rgb(var(--surface-rgb) / 0.96), rgb(var(--surface-rgb) / 0.74)), + color-mix(in srgb, var(--tab-tint, rgb(var(--steel-soft-rgb))) 34%, transparent); } .tab-status, @@ -214,7 +214,7 @@ } .tab-close:hover { - background: rgba(129, 153, 185, 0.14); + background: rgb(var(--slate-soft-rgb) / 0.14); color: var(--danger); } @@ -222,8 +222,8 @@ flex: 0 0 auto; width: 24px; padding: 0; - color: #5d7fae; - background: rgba(255, 255, 255, 0.5); + color: rgb(var(--text-steel-rgb)); + background: rgb(var(--surface-rgb) / 0.5); transition: transform 150ms ease, background 150ms ease, @@ -235,13 +235,13 @@ flex: 0 0 auto; min-width: 24px; height: 24px; - border: 1px solid rgba(125, 211, 252, 0.28); + border: 1px solid rgb(var(--sky-rgb) / 0.28); border-radius: 999px; padding: 0 7px; background: - radial-gradient(circle at 35% 22%, rgba(255, 255, 255, 0.9), transparent 44%), - rgba(229, 248, 255, 0.64); - color: #2d7197; + radial-gradient(circle at 35% 22%, rgb(var(--surface-rgb) / 0.9), transparent 44%), + rgb(var(--surface-tint-cool-rgb) / 0.64); + color: rgb(var(--ocean-rgb)); font-size: 10px; font-weight: 900; } @@ -252,18 +252,18 @@ display: grid; gap: 3px; width: 172px; - border: 1px solid rgba(159, 215, 245, 0.52); + border: 1px solid rgb(var(--sky-mist-rgb) / 0.52); border-radius: 8px; padding: 7px; background: - radial-gradient(circle at 18% 8%, rgba(255, 255, 255, 0.96), transparent 38%), - linear-gradient(145deg, rgba(248, 253, 255, 0.92), rgba(215, 241, 255, 0.72)), - rgba(239, 249, 255, 0.82); - color: #28465f; + radial-gradient(circle at 18% 8%, rgb(var(--surface-rgb) / 0.96), transparent 38%), + linear-gradient(145deg, rgb(var(--surface-tint-warm-rgb) / 0.92), rgb(var(--surface-sky-rgb) / 0.72)), + rgb(var(--surface-tint-rgb) / 0.82); + color: rgb(var(--soft-ink-rgb)); box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.96), + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.96), inset 0 -12px 28px rgba(148, 203, 233, 0.14), - 0 16px 34px rgba(53, 104, 148, 0.18); + 0 16px 34px rgb(var(--shadow-rgb) / 0.18); backdrop-filter: blur(24px) saturate(1.5); } @@ -273,16 +273,16 @@ z-index: -1; border-radius: 7px; background: - linear-gradient(120deg, rgba(255, 255, 255, 0.7), transparent 38%), - linear-gradient(300deg, rgba(118, 204, 244, 0.18), transparent 42%); + linear-gradient(120deg, rgb(var(--surface-rgb) / 0.7), transparent 38%), + linear-gradient(300deg, rgb(var(--sky-soft-rgb) / 0.18), transparent 42%); content: ''; } .tab-context-menu-title { overflow: hidden; - border-bottom: 1px solid rgba(110, 169, 205, 0.16); + border-bottom: 1px solid rgb(var(--accent-sky-rgb) / 0.16); padding: 1px 7px 6px; - color: rgba(48, 78, 105, 0.68); + color: rgb(var(--soft-ink-rgb) / 0.68); font-size: 10px; font-weight: 800; letter-spacing: 0.03em; @@ -300,7 +300,7 @@ border-radius: 6px; padding: 0 8px; background: transparent; - color: #28465f; + color: rgb(var(--soft-ink-rgb)); font-size: 11px; font-weight: 800; text-align: left; @@ -309,16 +309,16 @@ .tab-context-menu button:hover:not(:disabled), .tab-context-menu button:focus-visible:not(:disabled) { - border-color: rgba(130, 198, 233, 0.34); + border-color: rgb(var(--sky-soft-rgb) / 0.34); background: - linear-gradient(180deg, rgba(255, 255, 255, 0.8), rgba(220, 243, 255, 0.52)), - rgba(166, 220, 247, 0.2); - color: #1d5f88; + linear-gradient(180deg, rgb(var(--surface-rgb) / 0.8), rgb(var(--surface-sky-rgb) / 0.52)), + rgb(var(--sky-mist-rgb) / 0.2); + color: rgb(var(--ocean-deep-rgb)); outline: none; } .tab-context-menu button:disabled { - color: rgba(77, 101, 124, 0.36); + color: rgb(var(--slate-steel-rgb) / 0.36); cursor: default; } @@ -330,14 +330,14 @@ gap: 8px; min-height: 38px; min-width: 0; - border-top: 1px solid rgba(130, 158, 196, 0.12); + border-top: 1px solid rgb(var(--slate-soft-rgb) / 0.12); padding: 4px 14px 8px; overflow-x: auto; overflow-y: hidden; background: - linear-gradient(180deg, rgba(248, 252, 255, 0.92), rgba(255, 255, 255, 0.78)), - rgba(255, 255, 255, 0.86); - box-shadow: 0 10px 24px rgba(71, 104, 146, 0.08); + linear-gradient(180deg, rgb(var(--surface-tint-warm-rgb) / 0.92), rgb(var(--surface-rgb) / 0.78)), + rgb(var(--surface-rgb) / 0.86); + box-shadow: 0 10px 24px rgb(var(--slate-ocean-rgb) / 0.08); scrollbar-width: none; } @@ -351,7 +351,7 @@ gap: 7px; min-width: 0; margin-left: auto; - border-left: 1px solid rgba(133, 158, 193, 0.18); + border-left: 1px solid rgb(var(--slate-soft-rgb) / 0.18); padding-left: 10px; } @@ -373,8 +373,8 @@ } .browser-capture-dock .capture-page-button { - border-color: rgba(73, 126, 204, 0.28); - background: rgba(232, 243, 255, 0.76); + border-color: rgb(var(--steel-vivid-rgb) / 0.28); + background: rgb(var(--surface-tint-cool-rgb) / 0.76); color: var(--accent-strong); transition: transform 160ms ease, @@ -385,14 +385,14 @@ } .browser-capture-dock .capture-page-button:hover:not(:disabled) { - border-color: rgba(54, 122, 178, 0.42); + border-color: rgb(var(--azure-rgb) / 0.42); background: - radial-gradient(circle at 35% 18%, rgba(255, 255, 255, 0.98), transparent 42%), - linear-gradient(180deg, rgba(255, 255, 255, 0.84), rgba(219, 240, 255, 0.9)); - color: #1d6f97; + radial-gradient(circle at 35% 18%, rgb(var(--surface-rgb) / 0.98), transparent 42%), + linear-gradient(180deg, rgb(var(--surface-rgb) / 0.84), rgb(var(--surface-sky-rgb) / 0.9)); + color: rgb(var(--ocean-rgb)); box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.92), - 0 8px 18px rgba(70, 124, 180, 0.16); + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.92), + 0 8px 18px rgb(var(--azure-rgb) / 0.16); transform: translateY(-1px); } @@ -422,8 +422,8 @@ height: 25px; border-radius: 999px; padding: 0 11px; - background: rgba(255, 255, 255, 0.58); - color: #5b6f89; + background: rgb(var(--surface-rgb) / 0.58); + color: rgb(var(--slate-mid-rgb)); font-size: 11px; font-weight: 760; box-shadow: none; @@ -437,25 +437,25 @@ } .quick-action-chip:first-child { - border-color: rgba(73, 126, 204, 0.25); + border-color: rgb(var(--steel-vivid-rgb) / 0.25); background: - radial-gradient(circle at 18% 0%, rgba(255, 255, 255, 0.9), transparent 48%), - rgba(232, 243, 255, 0.72); + radial-gradient(circle at 18% 0%, rgb(var(--surface-rgb) / 0.9), transparent 48%), + rgb(var(--surface-tint-cool-rgb) / 0.72); color: var(--accent-strong); } .quick-action-chip:hover { - background: rgba(255, 255, 255, 0.92); + background: rgb(var(--surface-rgb) / 0.92); color: var(--ink); - box-shadow: 0 10px 22px rgba(84, 130, 199, 0.12); + box-shadow: 0 10px 22px rgb(var(--steel-vivid-rgb) / 0.12); transform: translateY(-1px); } .button:hover, .new-tab-button:hover { - background: rgba(255, 255, 255, 0.9); + background: rgb(var(--surface-rgb) / 0.9); color: var(--accent-strong); - box-shadow: 0 8px 18px rgba(84, 130, 199, 0.14); + box-shadow: 0 8px 18px rgb(var(--steel-vivid-rgb) / 0.14); transform: translateY(-1px); } @@ -471,7 +471,7 @@ .webview-underlay { inset: var(--top-bar-height) 0 0; - background: rgba(246, 249, 253, 0.42); + background: rgb(var(--surface-tint-warm-rgb) / 0.42); } /* Android: placeholder marking where the native per-tab WebView (layered @@ -480,5 +480,5 @@ .mobile-tab-view { position: absolute; inset: var(--top-bar-height) 0 0; - background: rgba(246, 249, 253, 0.42); + background: rgb(var(--surface-tint-warm-rgb) / 0.42); } diff --git a/src/renderer/src/assets/styles/crystallizer.css b/src/renderer/src/assets/styles/crystallizer.css index d5725bf..dd6a1cc 100644 --- a/src/renderer/src/assets/styles/crystallizer.css +++ b/src/renderer/src/assets/styles/crystallizer.css @@ -5,17 +5,17 @@ flex-direction: column; height: calc(100vh - 102px); overflow: hidden; - color: #102033; + color: rgb(var(--night-rgb)); background: linear-gradient( 135deg, - rgba(255, 255, 255, 0.78), - rgba(240, 250, 255, 0.68) 48%, - rgba(255, 252, 246, 0.72) + rgb(var(--surface-rgb) / 0.78), + rgb(var(--surface-tint-rgb) / 0.68) 48%, + rgb(var(--surface-rgb) / 0.72) ), linear-gradient( 90deg, - rgba(14, 165, 233, 0.08), + rgb(var(--accent-vivid-rgb) / 0.08), transparent 34%, rgba(20, 184, 166, 0.07) 66%, rgba(184, 110, 45, 0.06) @@ -29,8 +29,8 @@ content: ''; opacity: 0.46; background-image: - linear-gradient(rgba(14, 165, 233, 0.08) 1px, transparent 1px), - linear-gradient(90deg, rgba(14, 165, 233, 0.06) 1px, transparent 1px); + linear-gradient(rgb(var(--accent-vivid-rgb) / 0.08) 1px, transparent 1px), + linear-gradient(90deg, rgb(var(--accent-vivid-rgb) / 0.06) 1px, transparent 1px); background-size: 58px 58px; mask-image: linear-gradient(to bottom, black, transparent 88%); } @@ -44,9 +44,9 @@ justify-content: space-between; gap: 24px; padding: 14px 24px 14px 28px; - border-bottom: 1px solid rgba(125, 211, 252, 0.32); - background: rgba(255, 255, 255, 0.58); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.82); + border-bottom: 1px solid rgb(var(--sky-rgb) / 0.32); + background: rgb(var(--surface-rgb) / 0.58); + box-shadow: inset 0 1px 0 rgb(var(--highlight-rgb) / 0.82); backdrop-filter: blur(24px) saturate(1.12); } @@ -54,7 +54,7 @@ display: inline-flex; align-items: center; gap: 8px; - color: #64748b; + color: rgb(var(--slate-mid-rgb)); font-size: 10px; font-weight: 900; letter-spacing: 0.18em; @@ -73,13 +73,13 @@ .brand-crystal { flex: 0 0 auto; - color: #0ea5e9; - filter: drop-shadow(0 0 13px rgba(14, 165, 233, 0.38)); + color: rgb(var(--accent-vivid-rgb)); + filter: drop-shadow(0 0 13px rgb(var(--accent-vivid-rgb) / 0.38)); } .brand-cluster h1 { margin: 0; - color: #0f8cc2; + color: rgb(var(--text-accent-rgb)); font-family: var(--font-ice); font-size: 34px; font-style: italic; @@ -92,7 +92,7 @@ width: 1px; height: 30px; margin-left: 3px; - background: linear-gradient(to bottom, transparent, rgba(14, 165, 233, 0.45), transparent); + background: linear-gradient(to bottom, transparent, rgb(var(--accent-vivid-rgb) / 0.45), transparent); } .brand-copy { @@ -103,7 +103,7 @@ } .brand-subtitle { - color: #0f766e; + color: rgb(var(--teal-deep-rgb)); font-size: 11px; font-weight: 700; line-height: 1.1; @@ -145,11 +145,11 @@ width: 42px; min-width: 42px; height: 42px; - border: 1px solid rgba(14, 165, 233, 0.3); + border: 1px solid rgb(var(--accent-vivid-rgb) / 0.3); border-radius: 8px; - color: #0f8cc2; - background: rgba(255, 255, 255, 0.72); - box-shadow: 0 16px 36px rgba(8, 47, 73, 0.1); + color: rgb(var(--text-accent-rgb)); + background: rgb(var(--surface-rgb) / 0.72); + box-shadow: 0 16px 36px rgb(var(--depth-rgb) / 0.1); } .crystallizer-mark svg { @@ -158,7 +158,7 @@ } .crystallizer-brand-subtitle { - color: #0f8cc2; + color: rgb(var(--text-accent-rgb)); font-size: 9px; font-weight: 900; line-height: 1; @@ -168,7 +168,7 @@ .crystallizer-brand h1 { margin: 0; - color: #102033; + color: rgb(var(--night-rgb)); font-family: var(--font-old); font-size: 26px; font-weight: 800; @@ -190,13 +190,13 @@ min-width: 240px; height: 44px; padding: 0 14px; - color: #94a3b8; - background: rgba(255, 255, 255, 0.74); - border: 1px solid rgba(148, 163, 184, 0.24); + color: rgb(var(--slate-light-rgb)); + background: rgb(var(--surface-rgb) / 0.74); + border: 1px solid rgb(var(--slate-light-rgb) / 0.24); border-radius: 999px; box-shadow: - 0 12px 42px rgba(8, 47, 73, 0.08), - 0 1px 0 rgba(255, 255, 255, 0.8) inset; + 0 12px 42px rgb(var(--depth-rgb) / 0.08), + 0 1px 0 rgb(var(--highlight-rgb) / 0.8) inset; transition: border-color 160ms ease, box-shadow 160ms ease, @@ -204,19 +204,19 @@ } .crystallizer-search-shell:focus-within { - color: #0284c7; - border-color: rgba(14, 165, 233, 0.52); - background: rgba(255, 255, 255, 0.94); + color: rgb(var(--text-accent-rgb)); + border-color: rgb(var(--accent-vivid-rgb) / 0.52); + background: rgb(var(--surface-rgb) / 0.94); box-shadow: - 0 20px 62px rgba(14, 165, 233, 0.14), - 0 0 0 4px rgba(14, 165, 233, 0.09); + 0 20px 62px rgb(var(--accent-vivid-rgb) / 0.14), + 0 0 0 4px rgb(var(--accent-vivid-rgb) / 0.09); } .crystallizer-search-shell input { width: 100%; min-width: 0; padding: 0 0 0 11px; - color: #102033; + color: rgb(var(--night-rgb)); font-size: 14px; font-weight: 650; letter-spacing: 0; @@ -227,7 +227,7 @@ } .crystallizer-search-shell input::placeholder { - color: #94a3b8; + color: rgb(var(--slate-light-rgb)); font-weight: 600; } @@ -241,11 +241,11 @@ border-radius: 8px; white-space: nowrap; - color: #ffffff; - background: linear-gradient(135deg, #0f172a, #075985 58%, #0f766e); + color: rgb(var(--highlight-rgb)); + background: linear-gradient(135deg, rgb(var(--ink-abyss-rgb)), rgb(var(--marine-rgb)) 58%, rgb(var(--teal-deep-rgb))); box-shadow: - 0 16px 34px rgba(8, 47, 73, 0.24), - 0 1px 0 rgba(255, 255, 255, 0.28) inset; + 0 16px 34px rgb(var(--depth-rgb) / 0.24), + 0 1px 0 rgb(var(--highlight-rgb) / 0.28) inset; } .crystallizer-search button span { @@ -257,33 +257,33 @@ } .crystallizer-search button:hover { - background: linear-gradient(135deg, #0f172a, #075985 42%, #0f766e); + background: linear-gradient(135deg, rgb(var(--ink-abyss-rgb)), rgb(var(--marine-rgb)) 42%, rgb(var(--teal-deep-rgb))); box-shadow: - 0 20px 42px rgba(8, 47, 73, 0.28), - 0 1px 0 rgba(255, 255, 255, 0.38) inset; + 0 20px 42px rgb(var(--depth-rgb) / 0.28), + 0 1px 0 rgb(var(--highlight-rgb) / 0.38) inset; } .crystallizer-search .crystallizer-save-button { width: 92px; - border-color: rgba(15, 140, 194, 0.28); + border-color: rgb(var(--accent-deep-rgb) / 0.28); background: - linear-gradient(180deg, rgba(255, 255, 255, 0.66), rgba(239, 249, 255, 0.5)), - rgba(255, 255, 255, 0.68); - color: #0f8cc2; + linear-gradient(180deg, rgb(var(--surface-rgb) / 0.66), rgb(var(--surface-tint-rgb) / 0.5)), + rgb(var(--surface-rgb) / 0.68); + color: rgb(var(--text-accent-rgb)); box-shadow: - 0 12px 30px rgba(8, 47, 73, 0.08), - 0 1px 0 rgba(255, 255, 255, 0.72) inset; + 0 12px 30px rgb(var(--depth-rgb) / 0.08), + 0 1px 0 rgb(var(--highlight-rgb) / 0.72) inset; text-shadow: none; } .crystallizer-search .crystallizer-save-button:hover { - border-color: rgba(15, 140, 194, 0.46); + border-color: rgb(var(--accent-deep-rgb) / 0.46); background: - linear-gradient(180deg, rgba(255, 255, 255, 0.86), rgba(229, 248, 255, 0.68)), - rgba(255, 255, 255, 0.82); + linear-gradient(180deg, rgb(var(--surface-rgb) / 0.86), rgb(var(--surface-tint-cool-rgb) / 0.68)), + rgb(var(--surface-rgb) / 0.82); box-shadow: - 0 16px 34px rgba(8, 47, 73, 0.12), - 0 1px 0 rgba(255, 255, 255, 0.82) inset; + 0 16px 34px rgb(var(--depth-rgb) / 0.12), + 0 1px 0 rgb(var(--highlight-rgb) / 0.82) inset; } .atlas-heading span, @@ -292,7 +292,7 @@ display: inline-flex; align-items: center; gap: 8px; - color: #64748b; + color: rgb(var(--slate-mid-rgb)); font-size: 10px; font-weight: 900; line-height: 1; @@ -304,7 +304,7 @@ .dock-head h2 { margin: 7px 0 0; overflow: hidden; - color: #0f172a; + color: rgb(var(--ink-abyss-rgb)); font-size: clamp(20px, 2.3vw, 23px); font-weight: 850; line-height: 1.1; @@ -347,11 +347,11 @@ display: flex; align-items: center; gap: 7px; - border: 1px solid rgba(125, 211, 252, 0.28); + border: 1px solid rgb(var(--sky-rgb) / 0.28); border-radius: 8px; padding: 5px; - background: rgba(255, 255, 255, 0.76); - box-shadow: 0 16px 40px rgba(8, 47, 73, 0.1); + background: rgb(var(--surface-rgb) / 0.76); + box-shadow: 0 16px 40px rgb(var(--depth-rgb) / 0.1); backdrop-filter: blur(18px); } @@ -365,7 +365,7 @@ .crystallizer-tools span { width: 48px; - color: #475569; + color: rgb(var(--slate-deep-rgb)); font-size: 11px; font-weight: 900; text-align: center; @@ -394,10 +394,10 @@ .iceberg-shape { fill: url(#iceberg-body); - stroke: rgba(125, 211, 252, 0.46); + stroke: rgb(var(--sky-rgb) / 0.46); stroke-width: 5px; - filter: drop-shadow(0 46px 70px rgba(8, 47, 73, 0.22)) - drop-shadow(0 0 34px rgba(125, 211, 252, 0.28)); + filter: drop-shadow(0 46px 70px rgb(var(--depth-rgb) / 0.22)) + drop-shadow(0 0 34px rgb(var(--sky-rgb) / 0.28)); } .crystallizer-body.results-ready .iceberg-shape { @@ -429,42 +429,42 @@ } .iceberg-facet { - fill: rgba(255, 255, 255, 0.2); - stroke: rgba(255, 255, 255, 0.5); + fill: rgb(var(--highlight-rgb) / 0.2); + stroke: rgb(var(--highlight-rgb) / 0.5); stroke-width: 2px; pointer-events: none; } .iceberg-facet.soft { - fill: rgba(255, 255, 255, 0.12); + fill: rgb(var(--highlight-rgb) / 0.12); opacity: 0.62; } .iceberg-facet.low { fill: rgba(52, 78, 108, 0.12); - stroke: rgba(255, 255, 255, 0.24); + stroke: rgb(var(--highlight-rgb) / 0.24); } .iceberg-ridge { fill: none; - stroke: rgba(255, 255, 255, 0.58); + stroke: rgb(var(--highlight-rgb) / 0.58); stroke-linecap: round; stroke-width: 3px; pointer-events: none; } .iceberg-ridge.faint { - stroke: rgba(255, 255, 255, 0.32); + stroke: rgb(var(--highlight-rgb) / 0.32); stroke-width: 2px; } .waterline { - stroke: rgba(255, 255, 255, 0.88); + stroke: rgb(var(--highlight-rgb) / 0.88); stroke-width: 15px; } .waterline-label { - fill: rgba(71, 85, 105, 0.42); + fill: rgb(var(--slate-deep-rgb) / 0.42); font-size: 17px; font-weight: 920; letter-spacing: 0.24em; @@ -474,7 +474,7 @@ } .crystallizer-layer line { - stroke: color-mix(in srgb, var(--layer-accent) 20%, rgba(15, 140, 194, 0.1)); + stroke: color-mix(in srgb, var(--layer-accent) 20%, rgb(var(--accent-deep-rgb) / 0.1)); stroke-dasharray: 8 18; stroke-width: 1.8px; } @@ -485,15 +485,15 @@ } .crystallizer-layer-label rect { - fill: rgba(255, 255, 255, 0.28); - stroke: color-mix(in srgb, var(--layer-accent) 24%, rgba(255, 255, 255, 0.34)); + fill: rgb(var(--highlight-rgb) / 0.28); + stroke: color-mix(in srgb, var(--layer-accent) 24%, rgb(var(--highlight-rgb) / 0.34)); stroke-width: 1.1px; - filter: drop-shadow(0 9px 20px rgba(8, 47, 73, 0.05)); + filter: drop-shadow(0 9px 20px rgb(var(--depth-rgb) / 0.05)); } .layer-label-number-mark { - fill: color-mix(in srgb, var(--layer-accent) 84%, #ffffff); - stroke: rgba(255, 255, 255, 0.68); + fill: color-mix(in srgb, var(--layer-accent) 84%, rgb(var(--highlight-rgb))); + stroke: rgb(var(--highlight-rgb) / 0.68); stroke-width: 1.2px; } @@ -510,14 +510,14 @@ } .layer-label-number { - fill: #ffffff; + fill: rgb(var(--highlight-rgb)); font-size: 18px; font-weight: 920; text-anchor: middle; } .layer-label-name { - fill: color-mix(in srgb, var(--layer-accent) 72%, #102033); + fill: color-mix(in srgb, var(--layer-accent) 72%, rgb(var(--night-rgb))); font-size: 18px; font-weight: 900; letter-spacing: 0.08em; @@ -526,14 +526,14 @@ .layer-label-caption, .layer-depth-label { - fill: rgba(71, 85, 105, 0.5); + fill: rgb(var(--slate-deep-rgb) / 0.5); font-size: 11px; font-weight: 860; letter-spacing: 0.16em; } .layer-depth-label { - fill: rgba(71, 85, 105, 0.46); + fill: rgb(var(--slate-deep-rgb) / 0.46); font-size: 14px; text-anchor: end; } @@ -541,7 +541,7 @@ .depth-thread { fill: none; opacity: 0.62; - stroke: rgba(71, 85, 105, 0.16); + stroke: rgb(var(--slate-deep-rgb) / 0.16); stroke-linecap: round; stroke-width: 2.4px; transition: @@ -553,9 +553,9 @@ .depth-thread.is-highlighted { opacity: 0.96; - stroke: rgba(98, 216, 235, 0.9); + stroke: rgb(var(--aqua-rgb) / 0.9); stroke-width: 4px; - filter: drop-shadow(0 0 9px rgba(98, 216, 235, 0.42)); + filter: drop-shadow(0 0 9px rgb(var(--aqua-rgb) / 0.42)); } .crystallizer-body.results-ready .depth-thread { @@ -607,12 +607,12 @@ border-radius: 11px; padding: 9px 10px 9px 8px; background: - radial-gradient(circle at 18% 10%, rgba(255, 255, 255, 0.96), transparent 32%), - linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(248, 252, 255, 0.72)), + radial-gradient(circle at 18% 10%, rgb(var(--surface-rgb) / 0.96), transparent 32%), + linear-gradient(180deg, rgb(var(--surface-rgb) / 0.9), rgb(var(--surface-tint-warm-rgb) / 0.72)), color-mix(in srgb, var(--layer-accent) 5%, white); - color: #102033; + color: rgb(var(--night-rgb)); text-align: left; - box-shadow: 0 12px 28px rgba(8, 47, 73, 0.12); + box-shadow: 0 12px 28px rgb(var(--depth-rgb) / 0.12); transition: background 180ms ease, border-color 180ms ease, @@ -631,11 +631,11 @@ border-color: color-mix(in srgb, var(--layer-accent) 62%, white); border-left-color: var(--layer-accent); background: - radial-gradient(circle at 18% 10%, rgba(255, 255, 255, 1), transparent 34%), - linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 252, 255, 0.82)), + radial-gradient(circle at 18% 10%, rgb(var(--surface-rgb) / 1), transparent 34%), + linear-gradient(180deg, rgb(var(--surface-rgb) / 0.98), rgb(var(--surface-tint-warm-rgb) / 0.82)), color-mix(in srgb, var(--layer-accent) 9%, white); box-shadow: - 0 18px 36px rgba(8, 47, 73, 0.16), + 0 18px 36px rgb(var(--depth-rgb) / 0.16), 0 0 0 3px color-mix(in srgb, var(--layer-accent) 14%, transparent); } @@ -646,20 +646,20 @@ height: 24px; grid-row: 1 / span 2; border-radius: 999px; - background: color-mix(in srgb, var(--layer-accent) 88%, #ffffff); - color: #ffffff; + background: color-mix(in srgb, var(--layer-accent) 88%, rgb(var(--surface-rgb))); + color: rgb(var(--highlight-rgb)); font-size: 11px; font-weight: 900; box-shadow: 0 7px 16px color-mix(in srgb, var(--layer-accent) 18%, transparent), - inset 0 1px 0 rgba(255, 255, 255, 0.5); + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.5); } .ice-node strong { display: -webkit-box; min-width: 0; overflow: hidden; - color: #102033; + color: rgb(var(--night-rgb)); font-size: 13px; font-weight: 900; line-height: 1.18; @@ -672,7 +672,7 @@ .ice-node small { display: -webkit-box; overflow: hidden; - color: rgba(82, 97, 115, 0.74); + color: rgb(var(--slate-steel-rgb) / 0.74); font-size: 9.5px; font-weight: 680; line-height: 1.25; @@ -699,13 +699,13 @@ border-radius: 12px; padding: 11px 13px 13px 10px; background: - radial-gradient(circle at 18% 10%, rgba(255, 255, 255, 1), transparent 34%), - linear-gradient(180deg, rgba(255, 255, 255, 0.99), rgba(248, 252, 255, 0.86)), + radial-gradient(circle at 18% 10%, rgb(var(--surface-rgb) / 1), transparent 34%), + linear-gradient(180deg, rgb(var(--surface-rgb) / 0.99), rgb(var(--surface-tint-warm-rgb) / 0.86)), color-mix(in srgb, var(--layer-accent) 10%, white); - color: #102033; + color: rgb(var(--night-rgb)); text-align: left; box-shadow: - 0 24px 50px rgba(8, 47, 73, 0.26), + 0 24px 50px rgb(var(--depth-rgb) / 0.26), 0 0 0 3px color-mix(in srgb, var(--layer-accent) 18%, transparent); pointer-events: none; opacity: 0; @@ -729,19 +729,19 @@ grid-row: 1 / span 2; align-self: start; border-radius: 999px; - background: color-mix(in srgb, var(--layer-accent) 88%, #ffffff); - color: #ffffff; + background: color-mix(in srgb, var(--layer-accent) 88%, rgb(var(--surface-rgb))); + color: rgb(var(--highlight-rgb)); font-size: 11px; font-weight: 900; box-shadow: 0 7px 16px color-mix(in srgb, var(--layer-accent) 18%, transparent), - inset 0 1px 0 rgba(255, 255, 255, 0.5); + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.5); } .ice-raised-card strong { align-self: center; min-width: 0; - color: #102033; + color: rgb(var(--night-rgb)); font-size: 12px; font-weight: 900; line-height: 1.18; @@ -750,15 +750,15 @@ .ice-raised-card small { grid-column: 2; - color: rgba(60, 76, 96, 0.94); + color: rgb(var(--soft-ink-rgb) / 0.94); font-size: 10.5px; font-weight: 660; line-height: 1.34; } .text-shimmer { - --text-shimmer-base: rgba(51, 65, 85, 0.64); - --text-shimmer-highlight: rgba(255, 255, 255, 0.98); + --text-shimmer-base: rgb(var(--slate-darkest-rgb) / 0.64); + --text-shimmer-highlight: rgb(var(--highlight-rgb) / 0.98); --text-shimmer-duration: 2.4s; --text-shimmer-spread: 48px; display: inline-block; @@ -787,7 +787,7 @@ justify-items: center; gap: 14px; pointer-events: none; - color: #0f8cc2; + color: rgb(var(--text-accent-rgb)); text-align: center; } @@ -796,14 +796,14 @@ inset: 0; content: ''; background: - radial-gradient(circle at 50% 40%, rgba(255, 255, 255, 0.34), transparent 34%), - linear-gradient(180deg, rgba(239, 249, 255, 0.08), rgba(232, 244, 255, 0.18)); + radial-gradient(circle at 50% 40%, rgb(var(--surface-rgb) / 0.34), transparent 34%), + linear-gradient(180deg, rgb(var(--surface-tint-rgb) / 0.08), rgb(var(--surface-tint-cool-rgb) / 0.18)); backdrop-filter: blur(10px) saturate(1.12); -webkit-backdrop-filter: blur(10px) saturate(1.12); mask-image: radial-gradient( circle at 50% 50%, black 0 38%, - rgba(0, 0, 0, 0.7) 58%, + rgb(var(--mask-rgb) / 0.7) 58%, transparent 86% ); } @@ -816,16 +816,16 @@ min-width: min(360px, 72vw); min-height: 154px; overflow: hidden; - border: 1px solid rgba(186, 230, 253, 0.42); + border: 1px solid rgb(var(--sky-pale-rgb) / 0.42); border-radius: 18px; padding: 28px 42px; background: - radial-gradient(circle at 50% 20%, rgba(255, 255, 255, 0.98), transparent 42%), - linear-gradient(145deg, rgba(255, 255, 255, 0.7), rgba(238, 248, 255, 0.46)); + radial-gradient(circle at 50% 20%, rgb(var(--surface-rgb) / 0.98), transparent 42%), + linear-gradient(145deg, rgb(var(--surface-rgb) / 0.7), rgb(var(--surface-tint-rgb) / 0.46)); box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.86), - 0 28px 70px rgba(8, 47, 73, 0.16), - 0 0 0 1px rgba(255, 255, 255, 0.34); + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.86), + 0 28px 70px rgb(var(--depth-rgb) / 0.16), + 0 0 0 1px rgb(var(--highlight-rgb) / 0.34); backdrop-filter: blur(24px) saturate(1.2); } @@ -838,12 +838,12 @@ conic-gradient( from 120deg, transparent, - rgba(125, 211, 252, 0.18), + rgb(var(--sky-rgb) / 0.18), transparent 40%, - rgba(255, 255, 255, 0.34), + rgb(var(--surface-rgb) / 0.34), transparent 72% ), - radial-gradient(circle at 50% 50%, rgba(210, 247, 255, 0.4), transparent 42%); + radial-gradient(circle at 50% 50%, rgb(var(--surface-sky-rgb) / 0.4), transparent 42%); filter: blur(18px); opacity: 0.72; pointer-events: none; @@ -863,7 +863,7 @@ .crystallizer-empty h2 { margin: 0; margin-top: -30px; - color: #334155; + color: rgb(var(--slate-darkest-rgb)); font-family: var(--font-old); font-size: 25px; font-weight: 750; @@ -876,18 +876,18 @@ .crystallizer-empty.crystallizing .crystallizer-state-card { min-width: min(430px, 78vw); min-height: 236px; - border-color: rgba(186, 230, 253, 0.54); + border-color: rgb(var(--sky-pale-rgb) / 0.54); padding: 0; color: #10a9d2; background: - radial-gradient(circle at 50% 26%, rgba(255, 255, 255, 0.98), transparent 36%), - radial-gradient(circle at 32% 78%, rgba(125, 211, 252, 0.22), transparent 42%), - linear-gradient(145deg, rgba(255, 255, 255, 0.76), rgba(227, 246, 255, 0.48)); + radial-gradient(circle at 50% 26%, rgb(var(--surface-rgb) / 0.98), transparent 36%), + radial-gradient(circle at 32% 78%, rgb(var(--sky-rgb) / 0.22), transparent 42%), + linear-gradient(145deg, rgb(var(--surface-rgb) / 0.76), rgb(var(--surface-tint-cool-rgb) / 0.48)); box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.92), - 0 32px 82px rgba(8, 47, 73, 0.2), - 0 0 44px rgba(56, 189, 248, 0.22), - 0 0 0 1px rgba(255, 255, 255, 0.42); + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.92), + 0 32px 82px rgb(var(--depth-rgb) / 0.2), + 0 0 44px rgb(var(--sky-vivid-rgb) / 0.22), + 0 0 0 1px rgb(var(--highlight-rgb) / 0.42); isolation: isolate; } @@ -907,9 +907,9 @@ content: ''; border-radius: inherit; background: - radial-gradient(circle at 50% 28%, rgba(255, 255, 255, 0.42), transparent 35%), - linear-gradient(112deg, transparent 8%, rgba(255, 255, 255, 0.28) 16%, transparent 30%), - linear-gradient(248deg, transparent 14%, rgba(125, 211, 252, 0.2) 34%, transparent 62%); + radial-gradient(circle at 50% 28%, rgb(var(--surface-rgb) / 0.42), transparent 35%), + linear-gradient(112deg, transparent 8%, rgb(var(--surface-rgb) / 0.28) 16%, transparent 30%), + linear-gradient(248deg, transparent 14%, rgb(var(--sky-rgb) / 0.2) 34%, transparent 62%); filter: blur(0.4px); opacity: 0.68; pointer-events: none; @@ -928,16 +928,16 @@ background: conic-gradient( from 216deg at 50% 54%, transparent 0deg, - rgba(255, 255, 255, 0.58) 9deg, + rgb(var(--surface-rgb) / 0.58) 9deg, transparent 18deg, transparent 42deg, - rgba(125, 211, 252, 0.32) 49deg, + rgb(var(--sky-rgb) / 0.32) 49deg, transparent 62deg, transparent 116deg, - rgba(214, 245, 255, 0.52) 126deg, + rgb(var(--surface-sky-rgb) / 0.52) 126deg, transparent 140deg, transparent 214deg, - rgba(255, 255, 255, 0.38) 224deg, + rgb(var(--surface-rgb) / 0.38) 224deg, transparent 238deg, transparent 360deg ); @@ -951,7 +951,7 @@ .crystallizer-prismatic-aura { inset: -16%; background: - radial-gradient(circle at 50% 48%, rgba(255, 255, 255, 0.84), transparent 16%), + radial-gradient(circle at 50% 48%, rgb(var(--surface-rgb) / 0.84), transparent 16%), radial-gradient(circle at 48% 50%, rgba(56, 189, 248, 0.34), transparent 34%), radial-gradient(circle at 64% 38%, rgba(192, 132, 252, 0.16), transparent 38%), radial-gradient(circle at 34% 68%, rgba(34, 211, 238, 0.18), transparent 38%); @@ -976,9 +976,9 @@ .answer-crystallizing-orb .answer-loading-haze { background: - radial-gradient(circle at 50% 48%, rgba(255, 255, 255, 0.72), transparent 26%), - radial-gradient(circle at 50% 50%, rgba(186, 230, 253, 0.58), transparent 48%), - radial-gradient(circle at 44% 62%, rgba(125, 211, 252, 0.22), transparent 64%); + radial-gradient(circle at 50% 48%, rgb(var(--surface-rgb) / 0.72), transparent 26%), + radial-gradient(circle at 50% 50%, rgb(var(--sky-pale-rgb) / 0.58), transparent 48%), + radial-gradient(circle at 44% 62%, rgb(var(--sky-rgb) / 0.22), transparent 64%); } .crystallizer-quantum-core { @@ -989,20 +989,20 @@ width: 78px; height: 78px; margin-top: 2px; - border: 1px solid rgba(255, 255, 255, 0.66); + border: 1px solid rgb(var(--edge-rgb) / 0.66); border-radius: 999px; background: radial-gradient( circle at 50% 50%, - rgba(255, 255, 255, 0.98), - rgba(186, 230, 253, 0.44) 48%, - rgba(14, 165, 233, 0.1) 72% + rgb(var(--surface-rgb) / 0.98), + rgb(var(--sky-pale-rgb) / 0.44) 48%, + rgb(var(--accent-vivid-rgb) / 0.1) 72% ), - linear-gradient(180deg, rgba(255, 255, 255, 0.74), rgba(224, 247, 255, 0.42)); + linear-gradient(180deg, rgb(var(--surface-rgb) / 0.74), rgb(var(--surface-tint-cool-rgb) / 0.42)); box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.92), - 0 0 26px rgba(56, 189, 248, 0.38), - 0 0 68px rgba(14, 165, 233, 0.24); + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.92), + 0 0 26px rgb(var(--sky-vivid-rgb) / 0.38), + 0 0 68px rgb(var(--accent-vivid-rgb) / 0.24); animation: crystallizerCoreFloat 3.4s ease-in-out infinite; } @@ -1012,14 +1012,14 @@ inset: -14px; content: ''; border-radius: inherit; - border: 1px solid rgba(186, 230, 253, 0.38); + border: 1px solid rgb(var(--sky-pale-rgb) / 0.38); opacity: 0.8; animation: crystallizerHaloPulse 2.6s ease-in-out infinite; } .crystallizer-quantum-core::after { inset: -26px; - border-color: rgba(255, 255, 255, 0.3); + border-color: rgb(var(--edge-rgb) / 0.3); animation-delay: 620ms; } @@ -1063,8 +1063,8 @@ .crystallizer-shimmer-text { --text-shimmer-base: rgba(25, 93, 123, 0.72); - --text-shimmer-highlight: rgba(255, 255, 255, 1); - filter: drop-shadow(0 0 14px rgba(125, 211, 252, 0.42)); + --text-shimmer-highlight: rgb(var(--highlight-rgb) / 1); + filter: drop-shadow(0 0 14px rgb(var(--sky-rgb) / 0.42)); } .crystallizer-dock { @@ -1076,9 +1076,9 @@ z-index: 3; min-width: 0; gap: 12px; - border-left: 1px solid rgba(125, 211, 252, 0.26); + border-left: 1px solid rgb(var(--sky-rgb) / 0.26); padding: 14px; - background: rgba(255, 255, 255, 0.52); + background: rgb(var(--surface-rgb) / 0.52); backdrop-filter: blur(22px) saturate(1.08); } @@ -1097,7 +1097,7 @@ border-radius: 10px; padding: 7px 8px; box-shadow: none; - color: #475569; + color: rgb(var(--slate-deep-rgb)); text-align: left; transition: transform 150ms ease, @@ -1136,25 +1136,25 @@ } .layer-strip.compact button.active { - border-color: color-mix(in srgb, var(--layer-accent, #0f8cc2) 46%, white); - background: color-mix(in srgb, var(--layer-accent, #0f8cc2) 10%, white); - color: color-mix(in srgb, var(--layer-accent, #0f8cc2) 84%, #102033); + border-color: color-mix(in srgb, var(--layer-accent, rgb(var(--accent-deep-rgb))) 46%, white); + background: color-mix(in srgb, var(--layer-accent, rgb(var(--accent-deep-rgb))) 10%, white); + color: color-mix(in srgb, var(--layer-accent, rgb(var(--text-accent-rgb))) 84%, rgb(var(--night-rgb))); } .layer-strip.compact button:hover { - border-color: color-mix(in srgb, var(--layer-accent, #0f8cc2) 42%, white); - background: color-mix(in srgb, var(--layer-accent, #0f8cc2) 8%, white); - box-shadow: 0 9px 20px rgba(8, 47, 73, 0.08); + border-color: color-mix(in srgb, var(--layer-accent, rgb(var(--accent-deep-rgb))) 42%, white); + background: color-mix(in srgb, var(--layer-accent, rgb(var(--accent-deep-rgb))) 8%, white); + box-shadow: 0 9px 20px rgb(var(--depth-rgb) / 0.08); transform: translateY(-1px); } .layer-strip.compact button:disabled, .layer-strip.compact button:disabled:hover { opacity: 0.48; - border-color: rgba(148, 163, 184, 0.18); - background: rgba(241, 245, 249, 0.46); + border-color: rgb(var(--slate-light-rgb) / 0.18); + background: rgb(var(--surface-tint-rgb) / 0.46); box-shadow: none; - color: #94a3b8; + color: rgb(var(--slate-light-rgb)); cursor: not-allowed; transform: none; } @@ -1171,17 +1171,17 @@ width: 19px; height: 19px; border-radius: 6px; - background: color-mix(in srgb, var(--layer-accent, #0f8cc2) 82%, #ffffff); - color: #ffffff; + background: color-mix(in srgb, var(--layer-accent, rgb(var(--accent-deep-rgb))) 82%, rgb(var(--surface-rgb))); + color: rgb(var(--highlight-rgb)); } .layer-strip.compact svg { padding: 3px; - border: 1px solid rgba(125, 211, 252, 0.42); + border: 1px solid rgb(var(--sky-rgb) / 0.42); background: - linear-gradient(180deg, rgba(255, 255, 255, 0.72), rgba(239, 249, 255, 0.62)), - rgba(14, 165, 233, 0.08); - color: #0f8cc2; + linear-gradient(180deg, rgb(var(--surface-rgb) / 0.72), rgb(var(--surface-tint-rgb) / 0.62)), + rgb(var(--accent-vivid-rgb) / 0.08); + color: rgb(var(--text-accent-rgb)); } .layer-strip.compact strong { @@ -1202,8 +1202,8 @@ height: 20px; border-radius: 999px; padding: 0 5px; - background: rgba(15, 23, 42, 0.06); - color: #64748b; + background: rgb(var(--ink-abyss-rgb) / 0.06); + color: rgb(var(--slate-mid-rgb)); font-size: 10px; font-weight: 900; } @@ -1220,15 +1220,15 @@ max-width: calc(100% - 232px); align-items: center; gap: 4px; - border: 1px solid rgba(125, 211, 252, 0.26); + border: 1px solid rgb(var(--sky-rgb) / 0.26); border-radius: 18px; padding: 6px; background: - radial-gradient(circle at 14% 12%, rgba(255, 255, 255, 0.92), transparent 34%), - linear-gradient(135deg, rgba(255, 255, 255, 0.74), rgba(229, 248, 255, 0.5)); + radial-gradient(circle at 14% 12%, rgb(var(--surface-rgb) / 0.92), transparent 34%), + linear-gradient(135deg, rgb(var(--surface-rgb) / 0.74), rgb(var(--surface-tint-cool-rgb) / 0.5)); box-shadow: - 0 14px 34px rgba(8, 47, 73, 0.1), - inset 0 1px 0 rgba(255, 255, 255, 0.72); + 0 14px 34px rgb(var(--depth-rgb) / 0.1), + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.72); backdrop-filter: blur(18px) saturate(1.12); -webkit-backdrop-filter: blur(18px) saturate(1.12); } @@ -1243,23 +1243,23 @@ gap: 4px; border-radius: 999px; padding: 0 6px 0 4px; - background: rgba(255, 255, 255, 0.5); + background: rgb(var(--surface-rgb) / 0.5); line-height: 1; } .canvas-layer-hud.layer-strip.compact button:hover { background: - linear-gradient(135deg, rgba(255, 255, 255, 0.9), rgba(229, 248, 255, 0.72)), - color-mix(in srgb, var(--layer-accent, #0f8cc2) 7%, white); + linear-gradient(135deg, rgb(var(--surface-rgb) / 0.9), rgb(var(--surface-tint-cool-rgb) / 0.72)), + color-mix(in srgb, var(--layer-accent, rgb(var(--accent-deep-rgb))) 7%, white); box-shadow: - 0 8px 18px rgba(8, 47, 73, 0.1), - 0 0 0 2px color-mix(in srgb, var(--layer-accent, #0f8cc2) 10%, transparent); + 0 8px 18px rgb(var(--depth-rgb) / 0.1), + 0 0 0 2px color-mix(in srgb, var(--layer-accent, rgb(var(--accent-deep-rgb))) 10%, transparent); } .canvas-layer-hud.layer-strip.compact button.active { background: - linear-gradient(135deg, rgba(255, 255, 255, 0.86), rgba(224, 247, 255, 0.68)), - color-mix(in srgb, var(--layer-accent, #0f8cc2) 12%, white); + linear-gradient(135deg, rgb(var(--surface-rgb) / 0.86), rgb(var(--surface-tint-cool-rgb) / 0.68)), + color-mix(in srgb, var(--layer-accent, rgb(var(--accent-deep-rgb))) 12%, white); } .canvas-layer-hud.layer-strip.compact svg, @@ -1297,9 +1297,9 @@ border-radius: 8px; padding: 14px; background: - linear-gradient(180deg, rgba(255, 255, 255, 0.84), rgba(255, 255, 255, 0.62)), + linear-gradient(180deg, rgb(var(--surface-rgb) / 0.84), rgb(var(--surface-rgb) / 0.62)), color-mix(in srgb, var(--layer-accent) 7%, white); - box-shadow: 0 18px 42px rgba(8, 47, 73, 0.1); + box-shadow: 0 18px 42px rgb(var(--depth-rgb) / 0.1); } .crystallizer-body.results-ready .crystallizer-detail { @@ -1323,14 +1323,14 @@ } .crystallizer-detail h2 { - color: #102033; + color: rgb(var(--night-rgb)); font-size: 15px; font-weight: 900; line-height: 1.1; } .crystallizer-detail > span { - color: #64748b; + color: rgb(var(--slate-mid-rgb)); font-size: 11px; font-weight: 850; } @@ -1346,11 +1346,11 @@ display: inline-flex; min-height: 22px; align-items: center; - border: 1px solid color-mix(in srgb, var(--layer-accent) 24%, rgba(255, 255, 255, 0.58)); + border: 1px solid color-mix(in srgb, var(--layer-accent) 24%, rgb(var(--edge-rgb) / 0.58)); border-radius: 999px; padding: 0 8px; - background: rgba(255, 255, 255, 0.54); - color: color-mix(in srgb, var(--layer-accent) 70%, #102033); + background: rgb(var(--surface-rgb) / 0.54); + color: color-mix(in srgb, var(--layer-accent) 70%, rgb(var(--night-rgb))); font-size: 10px; font-style: normal; font-weight: 900; @@ -1360,7 +1360,7 @@ } .crystallizer-detail small { - color: #475569; + color: rgb(var(--slate-deep-rgb)); font-size: 12px; font-weight: 650; line-height: 1.45; @@ -1369,7 +1369,7 @@ .crystallizer-depth-reason { border-left: 2px solid color-mix(in srgb, var(--layer-accent) 42%, transparent); padding-left: 9px; - color: #64748b; + color: rgb(var(--slate-mid-rgb)); font-size: 10px; font-style: normal; font-weight: 650; @@ -1401,15 +1401,15 @@ } .crystal-button, .responsive-button:hover { - border-color: rgba(14, 165, 233, 0.58); + border-color: rgb(var(--accent-vivid-rgb) / 0.58); background: - radial-gradient(circle at 80% 12%, rgba(255, 255, 255, 0.98), transparent 24%), - linear-gradient(135deg, #ecfeff 0%, #bae6fd 18%, #e0f2fe 36%, #ffffff 100%); - color: #082f49; + radial-gradient(circle at 80% 12%, rgb(var(--surface-rgb) / 0.98), transparent 24%), + linear-gradient(135deg, rgb(var(--surface-tint-rgb)) 0%, rgb(var(--sky-pale-rgb)) 18%, rgb(var(--surface-sky-rgb)) 36%, rgb(var(--surface-rgb)) 100%); + color: rgb(var(--ink-deep-rgb)); box-shadow: - 0 0 0 4px rgba(125, 211, 252, 0.16), - 0 18px 34px rgba(14, 165, 233, 0.18), - inset 0 1px 0 rgba(255, 255, 255, 0.4); + 0 0 0 4px rgb(var(--sky-rgb) / 0.16), + 0 18px 34px rgb(var(--accent-vivid-rgb) / 0.18), + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.4); transform: translateY(-1px); } @@ -1420,26 +1420,39 @@ box-shadow 0.1s ease; } .model-setup-button:hover { - border-color: rgba(14, 165, 233, 0.58); + border-color: rgb(var(--accent-vivid-rgb) / 0.58); background: - radial-gradient(circle at 80% 12%, rgba(255, 255, 255, 0.98), transparent 24%), - linear-gradient(135deg, #ecfeff 0%, #bae6fd 18%, #e0f2fe 36%, #ffffff 100%); - color: #082f49; + radial-gradient(circle at 80% 12%, rgb(var(--surface-rgb) / 0.98), transparent 24%), + linear-gradient(135deg, rgb(var(--surface-tint-rgb)) 0%, rgb(var(--sky-pale-rgb)) 18%, rgb(var(--surface-sky-rgb)) 36%, rgb(var(--surface-rgb)) 100%); + color: rgb(var(--ink-deep-rgb)); box-shadow: - 0 0 0 4px rgba(125, 211, 252, 0.16), - 0 18px 34px rgba(14, 165, 233, 0.18), - inset 0 1px 0 rgba(255, 255, 255, 0.4); + 0 0 0 4px rgb(var(--sky-rgb) / 0.16), + 0 18px 34px rgb(var(--accent-vivid-rgb) / 0.18), + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.4); transform: translateY(-1px); } +/* Two actions when the topic is already covered by the library, one when it is + not. Wraps rather than shrinks, so neither label truncates in the narrow rail. */ +.crystallizer-detail-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.crystallizer-detail-actions > button { + flex: 1 1 auto; + min-width: 0; +} + .crystallizer-detail .explore-web-button { position: relative; overflow: hidden; - border-color: rgba(125, 211, 252, 0.34); + border-color: rgb(var(--sky-rgb) / 0.34); background: - radial-gradient(circle at 18% 18%, rgba(255, 255, 255, 0.92), transparent 28%), - linear-gradient(135deg, rgba(255, 255, 255, 0.78), rgba(224, 247, 255, 0.56)); - color: #075985; + radial-gradient(circle at 18% 18%, rgb(var(--surface-rgb) / 0.92), transparent 28%), + linear-gradient(135deg, rgb(var(--surface-rgb) / 0.78), rgb(var(--surface-tint-cool-rgb) / 0.56)); + color: rgb(var(--marine-rgb)); isolation: isolate; transition: transform 90ms ease, @@ -1459,12 +1472,12 @@ 112deg, transparent 0%, transparent 27%, - rgba(255, 255, 255, 0.94) 39%, - rgba(186, 230, 253, 0.86) 49%, - rgba(255, 255, 255, 0.9) 58%, + rgb(var(--surface-rgb) / 0.94) 39%, + rgb(var(--sky-pale-rgb) / 0.86) 49%, + rgb(var(--surface-rgb) / 0.9) 58%, transparent 72% ), - linear-gradient(135deg, #f0fdff 0%, #bae6fd 42%, #e0f2fe 68%, #ffffff 100%); + linear-gradient(135deg, rgb(var(--surface-tint-rgb)) 0%, rgb(var(--sky-pale-rgb)) 42%, rgb(var(--surface-sky-rgb)) 68%, rgb(var(--surface-rgb)) 100%); opacity: 0; transform: translateX(-72%) skewX(-12deg); transition: @@ -1473,15 +1486,15 @@ } .crystallizer-detail .explore-web-button:hover { - border-color: rgba(14, 165, 233, 0.58); + border-color: rgb(var(--accent-vivid-rgb) / 0.58); background: - radial-gradient(circle at 80% 12%, rgba(255, 255, 255, 0.98), transparent 24%), - linear-gradient(135deg, #ecfeff 0%, #bae6fd 38%, #e0f2fe 66%, #ffffff 100%); - color: #082f49; + radial-gradient(circle at 80% 12%, rgb(var(--surface-rgb) / 0.98), transparent 24%), + linear-gradient(135deg, rgb(var(--surface-tint-rgb)) 0%, rgb(var(--sky-pale-rgb)) 38%, rgb(var(--surface-sky-rgb)) 66%, rgb(var(--surface-rgb)) 100%); + color: rgb(var(--ink-deep-rgb)); box-shadow: - 0 0 0 4px rgba(125, 211, 252, 0.16), - 0 18px 34px rgba(14, 165, 233, 0.18), - inset 0 1px 0 rgba(255, 255, 255, 0.9); + 0 0 0 4px rgb(var(--sky-rgb) / 0.16), + 0 18px 34px rgb(var(--accent-vivid-rgb) / 0.18), + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.9); transform: translateY(-1px); } @@ -1499,15 +1512,15 @@ min-height: 138px; place-items: center; align-items: center; - border: 1px dashed rgba(100, 116, 139, 0.24); + border: 1px dashed rgb(var(--slate-mid-rgb) / 0.24); border-radius: 8px; - color: #64748b; + color: rgb(var(--slate-mid-rgb)); font-size: 12px; font-weight: 760; } .crystallizer-placeholder span { - color: #94a3b8; + color: rgb(var(--slate-light-rgb)); font-size: 11px; font-weight: 900; letter-spacing: 0.12em; @@ -1555,14 +1568,14 @@ } .crystallizer-list button.active { - border-color: color-mix(in srgb, var(--layer-accent, #0f8cc2) 38%, white); - background: color-mix(in srgb, var(--layer-accent, #0f8cc2) 10%, white); + border-color: color-mix(in srgb, var(--layer-accent, rgb(var(--accent-deep-rgb))) 38%, white); + background: color-mix(in srgb, var(--layer-accent, rgb(var(--accent-deep-rgb))) 10%, white); } .crystallizer-list button:hover { - border-color: color-mix(in srgb, var(--layer-accent, #0f8cc2) 34%, white); - background: color-mix(in srgb, var(--layer-accent, #0f8cc2) 7%, white); - box-shadow: 0 8px 18px rgba(8, 47, 73, 0.08); + border-color: color-mix(in srgb, var(--layer-accent, rgb(var(--accent-deep-rgb))) 34%, white); + background: color-mix(in srgb, var(--layer-accent, rgb(var(--accent-deep-rgb))) 7%, white); + box-shadow: 0 8px 18px rgb(var(--depth-rgb) / 0.08); } .crystallizer-list span { @@ -1571,17 +1584,17 @@ width: 22px; height: 22px; border-radius: 6px; - background: color-mix(in srgb, var(--layer-accent, #0f8cc2) 88%, #ffffff); - color: #ffffff; + background: color-mix(in srgb, var(--layer-accent, rgb(var(--accent-deep-rgb))) 88%, rgb(var(--surface-rgb))); + color: rgb(var(--highlight-rgb)); font-size: 11px; font-weight: 900; - box-shadow: 0 7px 16px color-mix(in srgb, var(--layer-accent, #0f8cc2) 18%, transparent); + box-shadow: 0 7px 16px color-mix(in srgb, var(--layer-accent, rgb(var(--accent-deep-rgb))) 18%, transparent); } .crystallizer-list strong { min-width: 0; overflow: hidden; - color: #334155; + color: rgb(var(--slate-darkest-rgb)); font-size: 12px; font-weight: 820; text-overflow: ellipsis; @@ -1595,7 +1608,7 @@ flex-direction: column; min-height: 0; gap: 7px; - border-top: 1px solid rgba(125, 211, 252, 0.22); + border-top: 1px solid rgb(var(--sky-rgb) / 0.22); padding-top: 8px; transition: flex-basis 220ms ease, @@ -1616,11 +1629,11 @@ align-items: center; width: 100%; height: 34px; - border-color: rgba(125, 211, 252, 0.22); + border-color: rgb(var(--sky-rgb) / 0.22); border-radius: 9px; padding: 0 8px; - background: rgba(239, 249, 255, 0.52); - color: #64748b; + background: rgb(var(--surface-tint-rgb) / 0.52); + color: rgb(var(--slate-mid-rgb)); box-shadow: none; } @@ -1643,9 +1656,9 @@ border-radius: 999px; padding: 0 6px; background: - radial-gradient(circle at 35% 20%, rgba(255, 255, 255, 0.9), transparent 44%), - rgba(15, 140, 194, 0.12); - color: #0f8cc2; + radial-gradient(circle at 35% 20%, rgb(var(--surface-rgb) / 0.9), transparent 44%), + rgb(var(--accent-deep-rgb) / 0.12); + color: rgb(var(--text-accent-rgb)); font-size: 10px; font-weight: bold; } @@ -1664,7 +1677,7 @@ max-height: 0; margin: 0; overflow: hidden; - color: #94a3b8; + color: rgb(var(--slate-light-rgb)); font-size: 11px; font-weight: 760; opacity: 0; @@ -1751,18 +1764,18 @@ display: grid; place-items: center; padding: 0; - color: #94a3b8; + color: rgb(var(--slate-light-rgb)); } .saved-atlas-list article.active > button:first-child { - border-color: rgba(15, 140, 194, 0.34); - background: rgba(231, 246, 255, 0.82); + border-color: rgb(var(--accent-deep-rgb) / 0.34); + background: rgb(var(--surface-tint-cool-rgb) / 0.82); } .saved-atlas-list strong { max-width: 100%; overflow: hidden; - color: #334155; + color: rgb(var(--slate-darkest-rgb)); font-size: 12px; font-weight: 800; text-overflow: ellipsis; @@ -1772,7 +1785,7 @@ } .saved-atlas-list small { - color: #64748b; + color: rgb(var(--slate-mid-rgb)); font-size: 10px; font-weight: 700; line-height: 1; @@ -1783,12 +1796,12 @@ @keyframes crystallizerIcebergReveal { from { opacity: 0.18; - filter: blur(10px) drop-shadow(0 18px 32px rgba(8, 47, 73, 0.08)); + filter: blur(10px) drop-shadow(0 18px 32px rgb(var(--depth-rgb) / 0.08)); } to { opacity: 1; - filter: blur(0) drop-shadow(0 46px 70px rgba(8, 47, 73, 0.22)) - drop-shadow(0 0 34px rgba(125, 211, 252, 0.28)); + filter: blur(0) drop-shadow(0 46px 70px rgb(var(--depth-rgb) / 0.22)) + drop-shadow(0 0 34px rgb(var(--sky-rgb) / 0.28)); } } diff --git a/src/renderer/src/assets/styles/dashboard-captures.css b/src/renderer/src/assets/styles/dashboard-captures.css index df57868..1872dfb 100644 --- a/src/renderer/src/assets/styles/dashboard-captures.css +++ b/src/renderer/src/assets/styles/dashboard-captures.css @@ -12,12 +12,12 @@ margin: 0 -34px 1px; overflow: hidden; padding: 68px 74px 48px; - border-bottom: 1px solid rgba(143, 165, 196, 0.16); + border-bottom: 1px solid rgb(var(--slate-rgb) / 0.16); background: - radial-gradient(circle at 52% 14%, rgba(255, 255, 255, 0.92), transparent 18%), - radial-gradient(circle at 72% 54%, rgba(126, 218, 238, 0.28), transparent 22%), - radial-gradient(circle at 86% 24%, rgba(143, 114, 207, 0.12), transparent 18%), - linear-gradient(180deg, rgba(255, 255, 255, 0.26), rgba(255, 255, 255, 0)); + radial-gradient(circle at 52% 14%, rgb(var(--surface-rgb) / 0.92), transparent 18%), + radial-gradient(circle at 72% 54%, rgb(var(--aqua-rgb) / 0.28), transparent 22%), + radial-gradient(circle at 86% 24%, rgb(var(--prism-rgb) / 0.12), transparent 18%), + linear-gradient(180deg, rgb(var(--surface-rgb) / 0.26), rgb(var(--surface-rgb) / 0)); } .dashboard-hero::before { @@ -28,9 +28,9 @@ height: 218px; border-radius: 50%; background: - radial-gradient(circle at 18% 58%, rgba(255, 255, 255, 0.95), transparent 28%), - radial-gradient(circle at 48% 42%, rgba(221, 249, 255, 0.82), transparent 34%), - radial-gradient(circle at 77% 54%, rgba(255, 255, 255, 0.9), transparent 30%); + radial-gradient(circle at 18% 58%, rgb(var(--surface-rgb) / 0.95), transparent 28%), + radial-gradient(circle at 48% 42%, rgb(var(--surface-tint-cool-rgb) / 0.82), transparent 34%), + radial-gradient(circle at 77% 54%, rgb(var(--surface-rgb) / 0.9), transparent 30%); content: ''; filter: blur(5px); } @@ -40,9 +40,9 @@ inset: 0; pointer-events: none; content: ''; - background-image: radial-gradient(circle, rgba(255, 255, 255, 0.96) 0 1.2px, transparent 1.5px); + background-image: radial-gradient(circle, rgb(var(--surface-rgb) / 0.96) 0 1.2px, transparent 1.5px); background-size: 64px 64px; - mask-image: linear-gradient(to bottom, #000, transparent 86%); + mask-image: linear-gradient(to bottom, rgb(var(--mask-rgb)), transparent 86%); } .dashboard-hero p, @@ -79,19 +79,26 @@ font-size: 4rem; font-weight: 800; line-height: 1.1; - color: #000; - - /* Icy Gradient Fill */ - background: linear-gradient(135deg, #000000 15%, #bbf1fa 70%, #7ed7ed 100%); + color: rgb(var(--wordmark-ink-rgb)); + + /* Icy Gradient Fill. The dark end is --wordmark-ink rather than a shadow + channel: it is the ink of a letterform, so it has to invert with the theme. + Left as black here it renders "ÆTH" invisible against the dark page. */ + background: linear-gradient( + 135deg, + rgb(var(--wordmark-ink-rgb)) 15%, + rgb(var(--glow-pale-rgb)) 70%, + rgb(var(--aqua-rgb)) 100% + ); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; /* Layered Text Glow (Chilled Haze) */ text-shadow: - 0 0 10px rgba(255, 255, 255, 0.6), - /* Crisp white core */ 0 0 25px rgba(126, 215, 237, 0.5), - /* Mid-tone icy blue mist */ 0 0 50px rgba(58, 146, 196, 0.3); /* Deep ambient arctic haze */ + 0 0 10px rgb(var(--highlight-rgb) / 0.6), + /* Crisp white core */ 0 0 25px rgb(var(--aqua-rgb) / 0.5), + /* Mid-tone icy blue mist */ 0 0 50px rgb(var(--accent-ocean-rgb) / 0.3); /* Deep ambient arctic haze */ } .start-page-hero-copy { @@ -112,9 +119,9 @@ z-index: -1; border-radius: 999px; background: - radial-gradient(circle at 24% 50%, rgba(103, 232, 249, 0.18), transparent 34%), - radial-gradient(circle at 52% 42%, rgba(255, 255, 255, 0.44), transparent 36%), - radial-gradient(circle at 78% 54%, rgba(143, 114, 207, 0.12), transparent 34%); + radial-gradient(circle at 24% 50%, rgb(var(--glow-rgb) / 0.18), transparent 34%), + radial-gradient(circle at 52% 42%, rgb(var(--surface-rgb) / 0.44), transparent 36%), + radial-gradient(circle at 78% 54%, rgb(var(--prism-rgb) / 0.12), transparent 34%); filter: blur(11px); opacity: 0.58; animation: discoverGlow 18s ease-in-out infinite alternate; @@ -125,20 +132,25 @@ bottom: -10px; left: 12%; height: 1px; - background: linear-gradient(90deg, transparent, rgba(80, 162, 196, 0.42), transparent); + background: linear-gradient(90deg, transparent, rgb(var(--accent-mid-rgb) / 0.42), transparent); } .start-page-hero-copy h1 { + /* The sweep animates a wide gradient across the letterforms, so both ends have + to stay readable. --wordmark-ink and --wordmark-ink-soft invert with the + theme; the bright middle stops are legible on either background as-is. */ --discover-gradient: linear-gradient( 105deg, - #071121 10%, - #102846 20%, - #1f7f9a 38%, - #e9fbff 50%, - #5eddec 60%, + rgb(var(--wordmark-ink-rgb)) 10%, + rgb(var(--wordmark-ink-soft-rgb)) 20%, + rgb(var(--ocean-rgb)) 38%, + rgb(var(--highlight-rgb)) 50%, + rgb(var(--aqua-rgb)) 60%, + /* Left as a literal: this violet is legible on either background, and every + themed channel near it is more than a hue-step away. */ #6657c8 76%, - #102846 90%, - #071121 100% + rgb(var(--wordmark-ink-soft-rgb)) 90%, + rgb(var(--wordmark-ink-rgb)) 100% ); position: relative; @@ -148,7 +160,7 @@ font-weight: 600; line-height: 0.86; letter-spacing: 0.064em; - color: #0a172b; + color: rgb(var(--ink-abyss-rgb)); background: var(--discover-gradient); background-size: 260% 100%; @@ -157,8 +169,8 @@ -webkit-text-fill-color: transparent; animation: discoverTextFlow 24s cubic-bezier(0.45, 0, 0.2, 1) infinite; - filter: drop-shadow(0 1px 0 rgba(255, 255, 255, 0.7)) - drop-shadow(0 8px 16px rgba(8, 25, 49, 0.16)) drop-shadow(0 0 14px rgba(103, 232, 249, 0.16)); + filter: drop-shadow(0 1px 0 rgb(var(--highlight-rgb) / 0.7)) + drop-shadow(0 8px 16px rgb(var(--ink-abyss-rgb) / 0.16)) drop-shadow(0 0 14px rgb(var(--glow-rgb) / 0.16)); } @keyframes discoverTextFlow { @@ -200,18 +212,18 @@ .dashboard-hero h1 { max-width: 680px; margin: 0; - color: #14233c; + color: rgb(var(--ink-rgb)); font-family: var(--font-old); font-size: 66px; font-weight: 600; letter-spacing: 0.12em; line-height: 1; - text-shadow: 0 18px 40px rgba(67, 127, 154, 0.12); + text-shadow: 0 18px 40px rgb(var(--shadow-soft-rgb) / 0.12); } .dashboard-hero p { margin-top: 16px; - color: #6f7fa0; + color: rgb(var(--muted-rgb)); } .hero-orb { @@ -235,9 +247,9 @@ border-radius: 999px; content: ''; background: - radial-gradient(circle at 48% 44%, rgba(255, 255, 255, 0.74), transparent 30%), - radial-gradient(circle at 50% 50%, rgba(186, 230, 253, 0.38), transparent 48%), - radial-gradient(circle at 50% 50%, rgba(45, 212, 191, 0.14), transparent 66%); + radial-gradient(circle at 48% 44%, rgb(var(--surface-rgb) / 0.74), transparent 30%), + radial-gradient(circle at 50% 50%, rgb(var(--sky-pale-rgb) / 0.38), transparent 48%), + radial-gradient(circle at 50% 50%, rgb(var(--aurora-vivid-rgb) / 0.14), transparent 66%); filter: blur(5px); opacity: 0.82; animation: heroOrbHalo 7.5s ease-in-out infinite; @@ -249,8 +261,8 @@ width: 300px; height: 300px; object-fit: contain; - filter: drop-shadow(0 26px 32px rgba(58, 115, 146, 0.2)) - drop-shadow(0 0 28px rgba(255, 255, 255, 0.86)); + filter: drop-shadow(0 26px 32px rgb(var(--ocean-rgb) / 0.2)) + drop-shadow(0 0 28px rgb(var(--highlight-rgb) / 0.86)); pointer-events: none; user-select: none; -webkit-user-drag: none; @@ -261,19 +273,19 @@ position: absolute; inset: 30px; overflow: hidden; - border: 1px solid rgba(255, 255, 255, 0.72); + border: 1px solid rgb(var(--edge-rgb) / 0.72); border-radius: 999px; background: - radial-gradient(circle at 36% 25%, rgba(255, 255, 255, 0.92), transparent 22%), - radial-gradient(circle at 66% 70%, rgba(98, 216, 198, 0.16), transparent 36%), - radial-gradient(circle, rgba(126, 215, 237, 0.18), transparent 62%); + radial-gradient(circle at 36% 25%, rgb(var(--surface-rgb) / 0.92), transparent 22%), + radial-gradient(circle at 66% 70%, rgb(var(--aurora-rgb) / 0.16), transparent 36%), + radial-gradient(circle, rgb(var(--aqua-rgb) / 0.18), transparent 62%); box-shadow: - inset 0 0 40px rgba(255, 255, 255, 0.6), - /* Brightened inner frost */ 0 0 20px rgba(255, 255, 255, 0.4), - /* Core white intense glow */ 0 0 60px rgba(185, 226, 250, 0.5), - /* Vivid icy cyan mid-glow */ 0 0 120px rgba(58, 146, 196, 0.3), - /* Deep arctic blue wide haze */ 0 30px 80px rgba(0, 0, 0, 0.15); /* Subtle grounding shadow */ + inset 0 0 40px rgb(var(--highlight-rgb) / 0.6), + /* Brightened inner frost */ 0 0 20px rgb(var(--highlight-rgb) / 0.4), + /* Core white intense glow */ 0 0 60px rgb(var(--sky-mist-rgb) / 0.5), + /* Vivid icy cyan mid-glow */ 0 0 120px rgb(var(--accent-ocean-rgb) / 0.3), + /* Deep arctic blue wide haze */ 0 30px 80px rgb(var(--shadow-cast-rgb) / 0.15); /* Subtle grounding shadow */ animation: heroOrbGlow 6.75s ease-in-out infinite; } @@ -289,12 +301,12 @@ .hero-orb-aura::before { background: - radial-gradient(circle at 38% 14%, rgba(255, 255, 255, 0.96), transparent 26%), + radial-gradient(circle at 38% 14%, rgb(var(--surface-rgb) / 0.96), transparent 26%), conic-gradient( from 30deg, transparent 0deg, - rgba(255, 255, 255, 0.58) 26deg, - rgba(125, 211, 252, 0.24) 58deg, + rgb(var(--surface-rgb) / 0.58) 26deg, + rgb(var(--sky-rgb) / 0.24) 58deg, transparent 100deg, transparent 360deg ); @@ -304,8 +316,8 @@ .hero-orb-aura::after { inset: 12px; - border: 1px solid rgba(255, 255, 255, 0.38); - background: radial-gradient(circle at 50% 18%, rgba(255, 255, 255, 0.82), transparent 36%); + border: 1px solid rgb(var(--edge-rgb) / 0.38); + background: radial-gradient(circle at 50% 18%, rgb(var(--surface-rgb) / 0.82), transparent 36%); opacity: 0.54; animation: heroOrbSheen 18s linear infinite reverse; } @@ -348,19 +360,19 @@ 0%, 100% { box-shadow: - inset 0 0 40px rgba(255, 255, 255, 0.6), - 0 0 20px rgba(255, 255, 255, 0.4), - 0 0 60px rgba(185, 226, 250, 0.5), - 0 0 120px rgba(58, 146, 196, 0.3), - 0 30px 80px rgba(0, 0, 0, 0.15); + inset 0 0 40px rgb(var(--highlight-rgb) / 0.6), + 0 0 20px rgb(var(--highlight-rgb) / 0.4), + 0 0 60px rgb(var(--sky-mist-rgb) / 0.5), + 0 0 120px rgb(var(--accent-ocean-rgb) / 0.3), + 0 30px 80px rgb(var(--shadow-cast-rgb) / 0.15); } 50% { box-shadow: - inset 0 0 48px rgba(255, 255, 255, 0.74), - 0 0 34px rgba(255, 255, 255, 0.86), - 0 0 88px rgba(185, 226, 250, 0.76), - 0 0 150px rgba(58, 146, 196, 0.44), - 0 34px 88px rgba(0, 0, 0, 0.12); + inset 0 0 48px rgb(var(--highlight-rgb) / 0.74), + 0 0 34px rgb(var(--highlight-rgb) / 0.86), + 0 0 88px rgb(var(--sky-mist-rgb) / 0.76), + 0 0 150px rgb(var(--accent-ocean-rgb) / 0.44), + 0 34px 88px rgb(var(--shadow-cast-rgb) / 0.12); } } @@ -389,9 +401,9 @@ inset: -40%; border-radius: 999px; background: - radial-gradient(circle at 50% 44%, rgba(255, 255, 255, 0.78), transparent 21%), - radial-gradient(circle at 42% 52%, rgba(125, 211, 252, 0.44), transparent 38%), - radial-gradient(circle at 62% 60%, rgba(45, 212, 191, 0.24), transparent 52%), + radial-gradient(circle at 50% 44%, rgb(var(--surface-rgb) / 0.78), transparent 21%), + radial-gradient(circle at 42% 52%, rgb(var(--sky-rgb) / 0.44), transparent 38%), + radial-gradient(circle at 62% 60%, rgb(var(--aurora-vivid-rgb) / 0.24), transparent 52%), radial-gradient(circle, rgba(120, 119, 255, 0.12), transparent 66%); filter: blur(10px); opacity: 0.95; @@ -404,11 +416,11 @@ background: conic-gradient( from 24deg, transparent 0deg, - rgba(255, 255, 255, 0.46) 20deg, - rgba(125, 211, 252, 0.22) 48deg, + rgb(var(--surface-rgb) / 0.46) 20deg, + rgb(var(--sky-rgb) / 0.22) 48deg, transparent 84deg, transparent 132deg, - rgba(186, 230, 253, 0.24) 164deg, + rgb(var(--sky-pale-rgb) / 0.24) 164deg, transparent 206deg, transparent 360deg ); @@ -423,8 +435,8 @@ width: 100%; height: 100%; object-fit: contain; - filter: drop-shadow(0 26px 32px rgba(58, 115, 146, 0.2)) - drop-shadow(0 0 28px rgba(255, 255, 255, 0.86)); + filter: drop-shadow(0 26px 32px rgb(var(--ocean-rgb) / 0.2)) + drop-shadow(0 0 28px rgb(var(--highlight-rgb) / 0.86)); pointer-events: none; user-select: none; -webkit-user-drag: none; @@ -435,19 +447,19 @@ position: absolute; inset: 13%; overflow: hidden; - border: 1px solid rgba(255, 255, 255, 0.72); + border: 1px solid rgb(var(--edge-rgb) / 0.72); border-radius: 999px; background: - radial-gradient(circle at 36% 25%, rgba(255, 255, 255, 0.92), transparent 22%), - radial-gradient(circle at 66% 70%, rgba(98, 216, 198, 0.16), transparent 36%), - radial-gradient(circle, rgba(126, 215, 237, 0.18), transparent 62%); + radial-gradient(circle at 36% 25%, rgb(var(--surface-rgb) / 0.92), transparent 22%), + radial-gradient(circle at 66% 70%, rgb(var(--aurora-rgb) / 0.16), transparent 36%), + radial-gradient(circle, rgb(var(--aqua-rgb) / 0.18), transparent 62%); box-shadow: - inset 0 0 40px rgba(255, 255, 255, 0.6), - /* Brightened inner frost */ 0 0 20px rgba(255, 255, 255, 0.84), - /* Core white intense glow */ 0 0 60px rgba(185, 226, 250, 0.75), - /* Vivid icy cyan mid-glow */ 0 0 120px rgba(58, 146, 196, 0.63), - /* Deep arctic blue wide haze */ 0 30px 80px rgba(0, 0, 0, 0.15); /* Subtle grounding shadow */ + inset 0 0 40px rgb(var(--highlight-rgb) / 0.6), + /* Brightened inner frost */ 0 0 20px rgb(var(--highlight-rgb) / 0.84), + /* Core white intense glow */ 0 0 60px rgb(var(--sky-mist-rgb) / 0.75), + /* Vivid icy cyan mid-glow */ 0 0 120px rgb(var(--accent-ocean-rgb) / 0.63), + /* Deep arctic blue wide haze */ 0 30px 80px rgb(var(--shadow-cast-rgb) / 0.15); /* Subtle grounding shadow */ animation: startOrbBreath 6.5s ease-in-out infinite; } @@ -456,8 +468,8 @@ width: 7px; height: 7px; border-radius: 999px; - background: radial-gradient(circle, #ffffff 0 24%, rgba(125, 211, 252, 0.8) 38%, transparent 72%); - filter: drop-shadow(0 0 10px rgba(125, 211, 252, 0.9)); + background: radial-gradient(circle, rgb(var(--surface-rgb)) 0 24%, rgb(var(--sky-rgb) / 0.8) 38%, transparent 72%); + filter: drop-shadow(0 0 10px rgb(var(--sky-rgb) / 0.9)); animation: startOrbSpark 4.8s ease-in-out infinite; } @@ -497,7 +509,7 @@ } .start-orb-aura::before { - background: radial-gradient(circle at 50% 16%, rgba(255, 255, 255, 0.9), transparent 38%); + background: radial-gradient(circle at 50% 16%, rgb(var(--surface-rgb) / 0.9), transparent 38%); opacity: 0.85; animation: startOrbOrbit 16s linear infinite; } @@ -506,7 +518,7 @@ background: conic-gradient( from 0deg, transparent 0deg, - rgba(255, 255, 255, 0.5) 36deg, + rgb(var(--surface-rgb) / 0.5) 36deg, transparent 96deg, transparent 360deg ); @@ -572,19 +584,19 @@ 0%, 100% { box-shadow: - inset 0 0 40px rgba(255, 255, 255, 0.6), - 0 0 20px rgba(255, 255, 255, 0.78), - 0 0 60px rgba(185, 226, 250, 0.62), - 0 0 120px rgba(58, 146, 196, 0.5), - 0 30px 80px rgba(0, 0, 0, 0.15); + inset 0 0 40px rgb(var(--highlight-rgb) / 0.6), + 0 0 20px rgb(var(--highlight-rgb) / 0.78), + 0 0 60px rgb(var(--sky-mist-rgb) / 0.62), + 0 0 120px rgb(var(--accent-ocean-rgb) / 0.5), + 0 30px 80px rgb(var(--shadow-cast-rgb) / 0.15); } 50% { box-shadow: - inset 0 0 48px rgba(255, 255, 255, 0.72), - 0 0 32px rgba(255, 255, 255, 0.96), - 0 0 86px rgba(185, 226, 250, 0.92), - 0 0 160px rgba(58, 146, 196, 0.82), - 0 30px 80px rgba(0, 0, 0, 0.15); + inset 0 0 48px rgb(var(--highlight-rgb) / 0.72), + 0 0 32px rgb(var(--highlight-rgb) / 0.96), + 0 0 86px rgb(var(--sky-mist-rgb) / 0.92), + 0 0 160px rgb(var(--accent-ocean-rgb) / 0.82), + 0 30px 80px rgb(var(--shadow-cast-rgb) / 0.15); } } @@ -606,8 +618,8 @@ border: 1px solid var(--line); border-radius: 16px; background: - linear-gradient(180deg, rgba(255, 255, 255, 0.84), rgba(255, 255, 255, 0.62)), - rgba(255, 255, 255, 0.7); + linear-gradient(180deg, rgb(var(--surface-rgb) / 0.84), rgb(var(--surface-rgb) / 0.62)), + rgb(var(--surface-rgb) / 0.7); box-shadow: var(--shadow); backdrop-filter: blur(22px) saturate(1.26); } @@ -652,9 +664,9 @@ .collection-accordion { overflow: hidden; - border: 1px solid rgba(145, 166, 195, 0.16); + border: 1px solid rgb(var(--slate-rgb) / 0.16); border-radius: 14px; - background: rgba(255, 255, 255, 0.34); + background: rgb(var(--surface-rgb) / 0.34); transition: border-color 180ms ease, background 180ms ease, @@ -668,24 +680,24 @@ } .collection-accordion.drop-target { - border-color: rgba(78, 178, 210, 0.44); + border-color: rgb(var(--accent-muted-rgb) / 0.44); background: - radial-gradient(circle at 18% 22%, rgba(255, 255, 255, 0.9), transparent 34%), - rgba(231, 249, 255, 0.72); + radial-gradient(circle at 18% 22%, rgb(var(--surface-rgb) / 0.9), transparent 34%), + rgb(var(--surface-tint-cool-rgb) / 0.72); box-shadow: - inset 0 0 0 1px rgba(255, 255, 255, 0.72), - 0 18px 38px rgba(68, 153, 197, 0.14); + inset 0 0 0 1px rgb(var(--highlight-rgb) / 0.72), + 0 18px 38px rgb(var(--sky-deep-rgb) / 0.14); transform: translateY(-1px); } .collection-accordion.reorder-target { border-color: rgba(116, 145, 218, 0.42); background: - radial-gradient(circle at 18% 22%, rgba(255, 255, 255, 0.92), transparent 34%), - rgba(235, 242, 255, 0.74); + radial-gradient(circle at 18% 22%, rgb(var(--surface-rgb) / 0.92), transparent 34%), + rgb(var(--surface-violet-rgb) / 0.74); box-shadow: - inset 0 0 0 1px rgba(255, 255, 255, 0.72), - 0 16px 34px rgba(86, 111, 178, 0.13); + inset 0 0 0 1px rgb(var(--highlight-rgb) / 0.72), + 0 16px 34px rgb(var(--steel-deep-rgb) / 0.13); transform: translateY(-1px); } @@ -746,25 +758,25 @@ } .collection-row-actions button:hover { - border-color: rgba(79, 174, 202, 0.28); + border-color: rgb(var(--accent-muted-rgb) / 0.28); background: - radial-gradient(circle at 30% 18%, rgba(255, 255, 255, 0.9), transparent 40%), - rgba(236, 249, 255, 0.76); + radial-gradient(circle at 30% 18%, rgb(var(--surface-rgb) / 0.9), transparent 40%), + rgb(var(--surface-tint-rgb) / 0.76); color: var(--accent-strong); - box-shadow: 0 8px 18px rgba(74, 145, 194, 0.1); + box-shadow: 0 8px 18px rgb(var(--sky-deep-rgb) / 0.1); transform: translateY(-1px); } .danger-button:hover { - border-color: rgba(184, 91, 89, 0.25); - background: rgba(255, 239, 239, 0.72); + border-color: rgb(var(--danger-rgb) / 0.25); + background: rgb(var(--surface-blush-rgb) / 0.72); color: var(--danger); } .collection-captures { - border-top: 1px solid rgba(145, 166, 195, 0.15); + border-top: 1px solid rgb(var(--slate-rgb) / 0.15); padding: 14px; - background: rgba(249, 252, 255, 0.42); + background: rgb(var(--surface-tint-warm-rgb) / 0.42); } .collection-capture-list { @@ -783,7 +795,7 @@ width: 42px; height: 42px; border-radius: 10px; - background: linear-gradient(145deg, #edf6ff, #ffffff); + background: linear-gradient(145deg, rgb(var(--surface-tint-rgb)), rgb(var(--surface-rgb))); color: #5c91dd; } @@ -818,10 +830,10 @@ .collection-row.active, .collection-row:hover { - border-color: rgba(86, 139, 218, 0.36); + border-color: rgb(var(--steel-rgb) / 0.36); background: - radial-gradient(circle at 16% 18%, rgba(255, 255, 255, 0.95), transparent 35%), - linear-gradient(135deg, rgba(233, 244, 255, 0.9), rgba(255, 255, 255, 0.78)); + radial-gradient(circle at 16% 18%, rgb(var(--surface-rgb) / 0.95), transparent 35%), + linear-gradient(135deg, rgb(var(--surface-tint-cool-rgb) / 0.9), rgb(var(--surface-rgb) / 0.78)); } .collection-row span { @@ -855,10 +867,10 @@ .recent-card { min-height: 148px; - border: 1px solid rgba(145, 166, 195, 0.22); + border: 1px solid rgb(var(--slate-rgb) / 0.22); border-radius: 14px; padding: 14px; - background: rgba(255, 255, 255, 0.62); + background: rgb(var(--surface-rgb) / 0.62); cursor: grab; transition: transform 150ms ease, @@ -869,16 +881,16 @@ } .recent-card:hover { - border-color: rgba(79, 174, 202, 0.25); + border-color: rgb(var(--accent-muted-rgb) / 0.25); background: - radial-gradient(circle at 22% 15%, rgba(255, 255, 255, 0.9), transparent 32%), - rgba(248, 253, 255, 0.78); - box-shadow: 0 14px 30px rgba(71, 122, 174, 0.12); + radial-gradient(circle at 22% 15%, rgb(var(--surface-rgb) / 0.9), transparent 32%), + rgb(var(--surface-tint-warm-rgb) / 0.78); + box-shadow: 0 14px 30px rgb(var(--steel-deep-rgb) / 0.12); transform: translateY(-2px); } .recent-card.dragging { - border-color: rgba(79, 174, 202, 0.34); + border-color: rgb(var(--accent-muted-rgb) / 0.34); opacity: 0.55; cursor: grabbing; transform: scale(0.985); @@ -889,8 +901,8 @@ .collection-capture-list .recent-card.reorder-target { border-color: rgba(116, 145, 218, 0.5); box-shadow: - inset 0 0 0 1px rgba(255, 255, 255, 0.7), - 0 12px 26px rgba(86, 111, 178, 0.16); + inset 0 0 0 1px rgb(var(--highlight-rgb) / 0.7), + 0 12px 26px rgb(var(--steel-deep-rgb) / 0.16); transform: translateY(-2px); } @@ -909,16 +921,16 @@ place-items: center; width: 28px; height: 28px; - border-color: rgba(145, 166, 195, 0.18); + border-color: rgb(var(--slate-rgb) / 0.18); border-radius: 9px; padding: 0; - background: rgba(255, 255, 255, 0.58); - color: #7b8ba6; + background: rgb(var(--surface-rgb) / 0.58); + color: rgb(var(--slate-cool-rgb)); box-shadow: none; } .recent-delete:hover { - border-color: rgba(184, 91, 89, 0.24); + border-color: rgb(var(--danger-rgb) / 0.24); color: var(--danger); } @@ -971,11 +983,11 @@ align-items: center; gap: 5px; overflow: hidden; - border: 1px solid rgba(113, 159, 203, 0.18); + border: 1px solid rgb(var(--steel-soft-rgb) / 0.18); border-radius: 999px; padding: 3px 8px; - background: rgba(234, 247, 252, 0.7); - color: #54718e; + background: rgb(var(--surface-tint-cool-rgb) / 0.7); + color: rgb(var(--slate-ocean-rgb)); font-size: 10px; font-weight: 760; text-overflow: ellipsis; @@ -992,7 +1004,7 @@ display: -webkit-box; margin-top: 8px; overflow: hidden; - color: #647895; + color: rgb(var(--slate-blue-rgb)); font-size: 12px; line-height: 1.45; -webkit-box-orient: vertical; @@ -1003,27 +1015,27 @@ .recent-card .data-badges span { border-radius: 7px; padding: 3px 8px; - background: rgba(233, 241, 253, 0.9); + background: rgb(var(--surface-sky-rgb) / 0.9); color: var(--accent); } .recent-card .data-badges time { border-radius: 7px; padding: 3px 8px; margin: 0 5px; - background: rgba(233, 241, 253, 0.9); - color: #5c85be; + background: rgb(var(--surface-sky-rgb) / 0.9); + color: rgb(var(--text-steel-rgb)); } .collection-capture-list .recent-card { min-height: 0; border-radius: 11px; padding: 10px 12px; - background: rgba(255, 255, 255, 0.72); + background: rgb(var(--surface-rgb) / 0.72); box-shadow: none; } .collection-capture-list .recent-card:hover { - box-shadow: 0 10px 22px rgba(71, 122, 174, 0.1); + box-shadow: 0 10px 22px rgb(var(--steel-deep-rgb) / 0.1); } .collection-capture-list .recent-source { @@ -1061,9 +1073,9 @@ } .answer-card { - border: 1px solid rgba(145, 166, 195, 0.22); + border: 1px solid rgb(var(--slate-rgb) / 0.22); border-radius: 12px; - background: rgba(255, 255, 255, 0.68); + background: rgb(var(--surface-rgb) / 0.68); } .empty-state { diff --git a/src/renderer/src/assets/styles/flow-map.css b/src/renderer/src/assets/styles/flow-map.css index ae5b3de..1c6a1d7 100644 --- a/src/renderer/src/assets/styles/flow-map.css +++ b/src/renderer/src/assets/styles/flow-map.css @@ -22,20 +22,20 @@ place-items: center; width: 48px; height: 48px; - border: 1px solid rgba(76, 160, 194, 0.24); + border: 1px solid rgb(var(--accent-mid-rgb) / 0.24); border-radius: 16px; background: - radial-gradient(circle at 32% 23%, rgba(255, 255, 255, 0.98), transparent 44%), - linear-gradient(145deg, rgba(216, 252, 255, 0.94), rgba(91, 179, 213, 0.72)); - color: #2a7f9d; + radial-gradient(circle at 32% 23%, rgb(var(--surface-rgb) / 0.98), transparent 44%), + linear-gradient(145deg, rgb(var(--surface-tint-cool-rgb) / 0.94), rgb(var(--accent-bright-rgb) / 0.72)); + color: rgb(var(--accent-strong-rgb)); box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.8), - 0 16px 32px rgba(64, 139, 172, 0.16); + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.8), + 0 16px 32px rgb(var(--shadow-soft-rgb) / 0.16); } .flow-brand h1 { margin: 0; - color: #123955; + color: rgb(var(--ink-teal-rgb)); font-size: 30px; font-weight: 880; line-height: 1; @@ -43,7 +43,7 @@ .flow-brand p { margin: 5px 0 0; - color: #52728d; + color: rgb(var(--slate-ocean-rgb)); font-size: 12px; font-weight: 760; } @@ -54,19 +54,19 @@ align-items: center; gap: 8px; height: 44px; - border: 1px solid rgba(93, 154, 190, 0.2); + border: 1px solid rgb(var(--steel-soft-rgb) / 0.2); border-radius: 14px; padding: 0 7px 0 13px; background: - radial-gradient(circle at 14% 24%, rgba(255, 255, 255, 0.96), transparent 42%), - linear-gradient(145deg, rgba(255, 255, 255, 0.78), rgba(219, 245, 255, 0.58)); + radial-gradient(circle at 14% 24%, rgb(var(--surface-rgb) / 0.96), transparent 42%), + linear-gradient(145deg, rgb(var(--surface-rgb) / 0.78), rgb(var(--surface-sky-rgb) / 0.58)); box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.82), - 0 14px 28px rgba(59, 121, 162, 0.1); + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.82), + 0 14px 28px rgb(var(--shadow-soft-rgb) / 0.1); } .flow-search svg { - color: #3d8fab; + color: rgb(var(--line-strong-rgb)); } .flow-search input { @@ -74,7 +74,7 @@ border: 0; padding: 0; background: transparent; - color: #173f5a; + color: rgb(var(--ink-teal-rgb)); font-size: 13px; font-weight: 720; box-shadow: none; @@ -107,15 +107,15 @@ min-width: 0; min-height: 0; overflow: hidden; - border: 1px solid rgba(97, 158, 196, 0.18); + border: 1px solid rgb(var(--steel-soft-rgb) / 0.18); border-radius: 8px; background: - radial-gradient(circle at 34% 22%, rgba(255, 255, 255, 0.88), transparent 22%), + radial-gradient(circle at 34% 22%, rgb(var(--surface-rgb) / 0.88), transparent 22%), radial-gradient(circle at 76% 72%, rgba(144, 228, 220, 0.14), transparent 32%), - linear-gradient(145deg, rgba(251, 254, 255, 0.76), rgba(224, 242, 249, 0.4)); + linear-gradient(145deg, rgb(var(--surface-tint-warm-rgb) / 0.76), rgb(var(--surface-sky-rgb) / 0.4)); box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.74), - 0 22px 46px rgba(51, 108, 154, 0.12); + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.74), + 0 22px 46px rgb(var(--ocean-rgb) / 0.12); } .flow-graph { @@ -127,7 +127,7 @@ .flow-current-field path { fill: none; - stroke: rgba(42, 132, 166, 0.105); + stroke: rgb(var(--shadow-soft-rgb) / 0.105); stroke-linecap: round; stroke-width: 7; stroke-dasharray: 1 24; @@ -155,15 +155,15 @@ } .flow-edge.contains { - stroke: rgba(65, 139, 172, 0.18); + stroke: rgb(var(--shadow-soft-rgb) / 0.18); } .flow-edge.semantic { - stroke: rgba(40, 148, 174, 0.3); + stroke: rgb(var(--accent-ocean-rgb) / 0.3); } .flow-edge.query-match { - stroke: rgba(42, 151, 126, 0.38); + stroke: rgb(var(--success-rgb) / 0.38); } .flow-edge.selected { @@ -182,8 +182,8 @@ } .flow-node:hover .flow-node-aura { - fill: rgba(78, 195, 218, 0.22); - stroke: rgba(45, 148, 190, 0.4); + fill: rgb(var(--accent-rgb) / 0.22); + stroke: rgb(var(--accent-ocean-rgb) / 0.4); } .flow-node.muted { @@ -191,13 +191,13 @@ } .flow-node-aura { - fill: rgba(126, 224, 240, 0.08); - stroke: rgba(73, 156, 186, 0.16); + fill: rgb(var(--aqua-rgb) / 0.08); + stroke: rgb(var(--sky-deep-rgb) / 0.16); stroke-width: 1.2; } .flow-node-core { - stroke: rgba(255, 255, 255, 0.88); + stroke: rgb(var(--highlight-rgb) / 0.88); stroke-width: 1.6; } @@ -214,8 +214,8 @@ } .flow-node.selected .flow-node-aura { - fill: rgba(78, 195, 218, 0.18); - stroke: rgba(45, 148, 190, 0.42); + fill: rgb(var(--accent-rgb) / 0.18); + stroke: rgb(var(--accent-ocean-rgb) / 0.42); stroke-width: 2.2; } @@ -226,8 +226,8 @@ /* Sources that matched the typed query: a teal ring ties them to the query node's current. */ .flow-node.matched .flow-node-aura { - fill: rgba(46, 166, 135, 0.2); - stroke: rgba(46, 166, 135, 0.46); + fill: rgb(var(--aurora-deep-rgb) / 0.2); + stroke: rgb(var(--aurora-deep-rgb) / 0.46); stroke-width: 2; } @@ -237,13 +237,13 @@ } .flow-node-label { - fill: #244e68; + fill: rgb(var(--ink-navy-rgb)); font-size: 12px; font-weight: 820; letter-spacing: 0; paint-order: stroke; pointer-events: none; - stroke: rgba(250, 254, 255, 0.86); + stroke: rgb(var(--highlight-rgb) / 0.86); stroke-linejoin: round; stroke-width: 5px; text-anchor: middle; @@ -257,7 +257,7 @@ justify-items: center; gap: 10px; padding: 24px; - color: #426985; + color: rgb(var(--text-ocean-rgb)); text-align: center; } @@ -266,14 +266,14 @@ } .flow-empty-state strong { - color: #163b55; + color: rgb(var(--ink-teal-rgb)); font-size: 18px; font-weight: 860; } .flow-empty-state span { max-width: 320px; - color: #5c7c93; + color: rgb(var(--slate-blue-rgb)); font-size: 13px; font-weight: 650; line-height: 1.4; @@ -303,21 +303,21 @@ display: grid; min-width: 0; gap: 2px; - border: 1px solid rgba(85, 149, 184, 0.16); + border: 1px solid rgb(var(--line-strong-rgb) / 0.16); border-radius: 8px; padding: 10px; - background: rgba(255, 255, 255, 0.58); - box-shadow: 0 12px 24px rgba(66, 128, 166, 0.08); + background: rgb(var(--surface-rgb) / 0.58); + box-shadow: 0 12px 24px rgb(var(--shadow-soft-rgb) / 0.08); } .flow-stats strong { - color: #173f5a; + color: rgb(var(--ink-teal-rgb)); font-size: 18px; font-weight: 880; } .flow-stats small { - color: #5e7d94; + color: rgb(var(--slate-blue-rgb)); font-size: 10px; font-weight: 820; text-transform: uppercase; @@ -327,14 +327,14 @@ .flow-recommendations, .flow-matches, .flow-omitted { - border: 1px solid rgba(87, 150, 184, 0.18); + border: 1px solid rgb(var(--line-strong-rgb) / 0.18); border-radius: 8px; background: - radial-gradient(circle at 20% 16%, rgba(255, 255, 255, 0.92), transparent 42%), - rgba(255, 255, 255, 0.58); + radial-gradient(circle at 20% 16%, rgb(var(--surface-rgb) / 0.92), transparent 42%), + rgb(var(--surface-rgb) / 0.58); box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.72), - 0 18px 34px rgba(58, 112, 152, 0.09); + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.72), + 0 18px 34px rgb(var(--ocean-rgb) / 0.09); } .flow-node-detail { @@ -357,15 +357,15 @@ height: 34px; border-radius: 10px; background: - radial-gradient(circle at 34% 24%, rgba(255, 255, 255, 0.98), transparent 45%), - linear-gradient(145deg, rgba(222, 252, 255, 0.95), rgba(97, 180, 213, 0.64)); - color: #287d9d; + radial-gradient(circle at 34% 24%, rgb(var(--surface-rgb) / 0.98), transparent 45%), + linear-gradient(145deg, rgb(var(--surface-tint-cool-rgb) / 0.95), rgb(var(--accent-sky-rgb) / 0.64)); + color: rgb(var(--accent-strong-rgb)); } .flow-node-detail small { display: block; overflow: hidden; - color: #5b7a92; + color: rgb(var(--slate-blue-rgb)); font-size: 11px; font-weight: 780; text-overflow: ellipsis; @@ -376,7 +376,7 @@ display: -webkit-box; margin: 3px 0 0; overflow: hidden; - color: #143b55; + color: rgb(var(--ink-teal-rgb)); font-size: 17px; font-weight: 860; line-height: 1.18; @@ -388,7 +388,7 @@ display: -webkit-box; margin: 12px 0 0; overflow: hidden; - color: #415f76; + color: rgb(var(--slate-steel-rgb)); font-size: 12px; font-weight: 620; line-height: 1.45; @@ -408,11 +408,11 @@ min-width: 0; max-width: 100%; overflow: hidden; - border: 1px solid rgba(69, 145, 174, 0.16); + border: 1px solid rgb(var(--line-strong-rgb) / 0.16); border-radius: 999px; padding: 4px 8px; - background: rgba(234, 251, 255, 0.7); - color: #377188; + background: rgb(var(--surface-tint-rgb) / 0.7); + color: rgb(var(--text-ocean-rgb)); font-size: 10px; font-weight: 820; text-overflow: ellipsis; @@ -433,7 +433,7 @@ height: 30px; border-radius: 9px; padding: 0 10px; - color: #2e7591; + color: rgb(var(--ocean-rgb)); font-size: 11px; } @@ -445,11 +445,11 @@ display: grid; justify-items: start; gap: 8px; - color: #55768f; + color: rgb(var(--slate-ocean-rgb)); } .flow-node-detail.empty strong { - color: #173f5a; + color: rgb(var(--ink-teal-rgb)); font-size: 15px; font-weight: 840; } @@ -467,7 +467,7 @@ } .flow-recommendations strong { - color: #173f5a; + color: rgb(var(--ink-teal-rgb)); font-size: 12px; font-weight: 860; } @@ -476,13 +476,13 @@ justify-content: flex-start; height: 30px; border-radius: 9px; - color: #52728c; + color: rgb(var(--slate-ocean-rgb)); font-size: 11px; box-shadow: none; } .flow-hint { - color: #5c7c93; + color: rgb(var(--slate-blue-rgb)); font-size: 12px; font-weight: 640; line-height: 1.4; @@ -499,7 +499,7 @@ .flow-matches > strong { overflow: hidden; - color: #173f5a; + color: rgb(var(--ink-teal-rgb)); font-size: 12px; font-weight: 860; text-overflow: ellipsis; @@ -514,7 +514,7 @@ margin: -4px; padding: 4px 6px 4px 4px; overflow-y: auto; - scrollbar-color: rgba(82, 178, 219, 0.38) transparent; + scrollbar-color: rgb(var(--accent-bright-rgb) / 0.38) transparent; scrollbar-width: thin; } @@ -527,10 +527,10 @@ min-height: 96px; height: 96px; overflow: hidden; - border: 1px solid rgba(87, 150, 184, 0.16); + border: 1px solid rgb(var(--line-strong-rgb) / 0.16); border-radius: 11px; padding: 10px 12px 10px 10px; - background: rgba(255, 255, 255, 0.62); + background: rgb(var(--surface-rgb) / 0.62); text-align: left; cursor: pointer; transition: @@ -539,13 +539,13 @@ } .flow-match:hover { - border-color: rgba(46, 166, 135, 0.4); - background: rgba(232, 251, 246, 0.82); + border-color: rgb(var(--aurora-deep-rgb) / 0.4); + background: rgb(var(--surface-mint-rgb) / 0.82); } .flow-match.active { - border-color: rgba(46, 166, 135, 0.64); - background: rgba(224, 248, 240, 0.92); + border-color: rgb(var(--aurora-deep-rgb) / 0.64); + background: rgb(var(--surface-mint-rgb) / 0.92); } .flow-match-score { @@ -555,11 +555,11 @@ width: 40px; height: 40px; border-radius: 999px; - border: 1px solid rgba(46, 166, 135, 0.28); + border: 1px solid rgb(var(--aurora-deep-rgb) / 0.28); background: - radial-gradient(circle at 34% 26%, rgba(255, 255, 255, 0.95), transparent 52%), - rgba(46, 166, 135, 0.14); - color: #1b7259; + radial-gradient(circle at 34% 26%, rgb(var(--surface-rgb) / 0.95), transparent 52%), + rgb(var(--aurora-deep-rgb) / 0.14); + color: rgb(var(--text-success-rgb)); font-size: 13px; font-weight: 880; font-variant-numeric: tabular-nums; @@ -575,7 +575,7 @@ .flow-match-meta { overflow: hidden; - color: #5e7d94; + color: rgb(var(--slate-blue-rgb)); font-size: 10px; font-weight: 800; text-overflow: ellipsis; @@ -585,7 +585,7 @@ .flow-match-copy strong { display: block; overflow: hidden; - color: #173f5a; + color: rgb(var(--ink-teal-rgb)); font-size: 13px; font-weight: 840; line-height: 1.18; @@ -597,7 +597,7 @@ display: block; overflow: hidden; margin-top: 1px; - color: #41566c; + color: rgb(var(--slate-steel-rgb)); font-size: 11px; font-weight: 560; line-height: 1.25; @@ -616,11 +616,11 @@ } .flow-match-strength { - border: 1px solid rgba(46, 166, 135, 0.22); + border: 1px solid rgb(var(--aurora-deep-rgb) / 0.22); border-radius: 999px; padding: 2px 8px; - background: rgba(224, 248, 240, 0.8); - color: #1b7259; + background: rgb(var(--surface-mint-rgb) / 0.8); + color: rgb(var(--text-success-rgb)); font-size: 9.5px; font-weight: 820; } @@ -632,7 +632,7 @@ gap: 3px; border-radius: 999px; padding: 2px 7px; - color: #2f7591; + color: rgb(var(--ocean-rgb)); font-size: 9.5px; font-weight: 800; cursor: pointer; @@ -640,12 +640,12 @@ } .flow-match-open:hover { - background: rgba(228, 245, 252, 0.9); + background: rgb(var(--surface-tint-cool-rgb) / 0.9); } .flow-omitted { padding: 10px 12px; - color: #5a768d; + color: rgb(var(--slate-mid-rgb)); font-size: 11px; font-weight: 680; line-height: 1.35; diff --git a/src/renderer/src/assets/styles/flow-view.css b/src/renderer/src/assets/styles/flow-view.css index 953ee4e..a130224 100644 --- a/src/renderer/src/assets/styles/flow-view.css +++ b/src/renderer/src/assets/styles/flow-view.css @@ -7,10 +7,10 @@ overflow: hidden; padding: 20px 24px 24px; background: - radial-gradient(circle at 14% 14%, rgba(255, 255, 255, 0.94), transparent 18%), + radial-gradient(circle at 14% 14%, rgb(var(--surface-rgb) / 0.94), transparent 18%), radial-gradient(circle at 78% 18%, rgba(147, 239, 255, 0.18), transparent 26%), radial-gradient(circle at 48% 84%, rgba(73, 167, 174, 0.1), transparent 32%), - linear-gradient(180deg, rgba(250, 254, 255, 0.78), rgba(235, 248, 252, 0.56)); + linear-gradient(180deg, rgb(var(--surface-tint-warm-rgb) / 0.78), rgb(var(--surface-tint-rgb) / 0.56)); } .flow-view::before { @@ -19,8 +19,8 @@ pointer-events: none; content: ''; background-image: - linear-gradient(rgba(77, 143, 180, 0.045) 1px, transparent 1px), - linear-gradient(90deg, rgba(77, 143, 180, 0.035) 1px, transparent 1px); + linear-gradient(rgb(var(--sky-deep-rgb) / 0.045) 1px, transparent 1px), + linear-gradient(90deg, rgb(var(--sky-deep-rgb) / 0.035) 1px, transparent 1px); background-size: 56px 56px; - mask-image: radial-gradient(circle at 50% 42%, rgba(0, 0, 0, 0.72), transparent 76%); + mask-image: radial-gradient(circle at 50% 42%, rgb(var(--mask-rgb) / 0.72), transparent 76%); } diff --git a/src/renderer/src/assets/styles/foundation.css b/src/renderer/src/assets/styles/foundation.css index da3a18f..f40bc30 100644 --- a/src/renderer/src/assets/styles/foundation.css +++ b/src/renderer/src/assets/styles/foundation.css @@ -1,29 +1,181 @@ +/* --------------------------------------------------------------------------- + Theme channels + --------------------------------------------------------------------------- + Colour is expressed as bare `R G B` channel triplets, consumed as + `rgb(var(--channel) / )`. This exists because the single most common + colour in the app is `rgba(255, 255, 255, α)` — 446 uses across 50 distinct + alphas, the frosted-glass surface idiom. Naming one token per alpha would + mean 50 tokens for one colour; one channel plus the call site's own alpha + covers every one of them and flips the whole app with a single override. + + Every light-mode channel below is the exact value it replaced, so the light + theme is unchanged by construction — see the `theme-tokens` test. + + Dark values are set in the `prefers-color-scheme` block further down, and + `:root[data-theme]` beats both so an explicit choice always wins. + --------------------------------------------------------------------------- */ :root { + color-scheme: light; + --rail-width: 76px; --titlebar-height: 24px; --nav-height: 116px; --top-bar-height: 150px; --panel-width: 404px; --panel-collapsed-width: 58px; - --ink: #17243a; - --soft-ink: #344766; - --muted: #71819a; - --faint: #9daabe; - --line: rgba(123, 158, 190, 0.28); - --line-strong: rgba(72, 143, 179, 0.34); - --glass: rgba(255, 255, 255, 0.72); - --glass-strong: rgba(255, 255, 255, 0.9); - --cloud: #f7fbff; - --aura: #dff8ff; - --accent: #4eb8df; - --accent-strong: #247fa7; - --prism: #8f72cf; - --aurora: #62d8c6; - --glint: #f1c66b; - --success: #2b9270; - --danger: #b85b59; - --shadow: 0 18px 55px rgba(51, 103, 137, 0.16); - --soft-shadow: 0 10px 30px rgba(62, 132, 163, 0.11); + + /* --- Surfaces ----------------------------------------------------------- + --surface is the frosted glass everything sits on, consumed at ~50 different + alphas. The tints are the pale washes used as gradient stops inside cards. */ + --surface-rgb: 255 255 255; + --surface-tint-rgb: 239 249 255; + --surface-tint-cool-rgb: 229 248 255; + --surface-tint-warm-rgb: 248 253 255; + --surface-sky-rgb: 224 242 254; + --surface-mint-rgb: 232 251 246; + --surface-violet-rgb: 244 239 255; + --surface-blush-rgb: 255 239 239; + --surface-gold-rgb: 250 240 214; + + /* --highlight is white used as *light*: bevels, glows, and text sitting on a + coloured fill. Identical to --surface in this theme, and deliberately not + merged with it — in dark mode surfaces go dark while highlights must stay + light. --edge is white used as a border, which needs a third value again. */ + --highlight-rgb: 255 255 255; + --edge-rgb: 255 255 255; + + /* --- Ink ----------------------------------------------------------------- */ + --ink-rgb: 23 36 58; + --soft-ink-rgb: 52 71 102; + --muted-rgb: 113 129 154; + --faint-rgb: 157 170 190; + --night-rgb: 16 32 51; + --ink-deep-rgb: 8 47 73; + --ink-abyss-rgb: 15 23 42; + --ink-slate-rgb: 29 52 79; + --ink-teal-rgb: 20 59 85; + --ink-navy-rgb: 26 70 112; + + /* --- Structural greys --------------------------------------------------- */ + --slate-rgb: 143 165 196; + --slate-soft-rgb: 133 158 193; + --slate-mid-rgb: 100 116 139; + --slate-light-rgb: 148 163 184; + --slate-deep-rgb: 71 85 105; + --slate-darkest-rgb: 51 65 85; + --slate-blue-rgb: 94 125 148; + --slate-steel-rgb: 65 97 119; + --slate-ocean-rgb: 79 113 140; + --slate-cool-rgb: 123 139 166; + --line-rgb: 123 158 190; + --line-strong-rgb: 72 143 179; + + /* --- Depth --------------------------------------------------------------- + Shadows are cast in blue-grey rather than black, which is why they read as + light rather than dirty. --mask is alpha-only (mask-image), so its channels + carry no meaning and are never themed. */ + --shadow-rgb: 51 103 137; + --shadow-soft-rgb: 62 132 163; + --shadow-cast-rgb: 0 0 0; + --depth-rgb: 8 47 73; + --mask-rgb: 0 0 0; + + /* --- Brand hues ---------------------------------------------------------- + The identity. Most survive a dark background as-is; the few that go muddy or + glaring are overridden in the dark block. */ + --accent-rgb: 78 184 223; + --accent-strong-rgb: 36 127 167; + --accent-deep-rgb: 15 140 194; + --accent-vivid-rgb: 14 165 233; + --accent-mid-rgb: 75 163 206; + --accent-soft-rgb: 105 188 224; + --accent-muted-rgb: 79 174 202; + --accent-bright-rgb: 82 178 219; + --accent-pale-rgb: 142 219 255; + --accent-ocean-rgb: 45 148 190; + --accent-sky-rgb: 97 169 210; + --accent-light-rgb: 106 193 229; + --sky-rgb: 125 211 252; + --sky-pale-rgb: 186 230 253; + --sky-deep-rgb: 74 145 194; + --sky-mist-rgb: 166 220 247; + --sky-soft-rgb: 126 205 233; + --sky-muted-rgb: 139 190 215; + --sky-vivid-rgb: 56 189 248; + --steel-rgb: 86 139 218; + --steel-soft-rgb: 109 151 200; + --steel-mid-rgb: 94 132 185; + --steel-deep-rgb: 75 114 170; + --steel-vivid-rgb: 73 126 204; + --ocean-rgb: 40 113 150; + --ocean-deep-rgb: 29 95 136; + --marine-rgb: 7 89 133; + --azure-rgb: 54 122 178; + --aqua-rgb: 113 217 234; + --glow-rgb: 101 226 255; + --glow-pale-rgb: 188 245 255; + --prism-rgb: 143 114 207; + --aurora-rgb: 98 216 198; + --aurora-deep-rgb: 46 166 135; + --aurora-vivid-rgb: 45 212 191; + --teal-deep-rgb: 15 118 110; + --glint-rgb: 241 198 107; + --glint-deep-rgb: 109 76 21; + --success-rgb: 43 146 112; + --danger-rgb: 184 91 89; + --warning-rgb: 180 105 14; + + /* --- Composed ----------------------------------------------------------- */ + --ink: rgb(var(--ink-rgb)); + --soft-ink: rgb(var(--soft-ink-rgb)); + --muted: rgb(var(--muted-rgb)); + --faint: rgb(var(--faint-rgb)); + --line: rgb(var(--line-rgb) / 0.28); + --line-strong: rgb(var(--line-strong-rgb) / 0.34); + --glass: rgb(var(--surface-rgb) / 0.72); + --glass-strong: rgb(var(--surface-rgb) / 0.9); + --cloud: rgb(var(--surface-tint-warm-rgb)); + --aura: rgb(var(--glow-pale-rgb)); + --accent: rgb(var(--accent-rgb)); + --accent-strong: rgb(var(--accent-strong-rgb)); + --prism: rgb(var(--prism-rgb)); + --aurora: rgb(var(--aurora-rgb)); + --glint: rgb(var(--glint-rgb)); + --success: rgb(var(--success-rgb)); + --danger: rgb(var(--danger-rgb)); + /* Text-only aliases of three channels that also carry shadows and borders. A + shadow has to get darker in dark mode and text has to get lighter, so a + single channel cannot serve both. Identical light values, so splitting them + changes nothing here. */ + --text-ocean-rgb: 51 103 137; + --text-steel-rgb: 94 132 185; + --text-accent-rgb: 15 140 194; + + /* The ink end of the ÆTHER wordmark gradients. A letterform's dark end is text, + not a shadow, so it has to invert — left on a shadow channel it renders the + first three letters invisible against the dark page. */ + --wordmark-ink-rgb: 0 0 0; + --wordmark-ink-soft-rgb: 16 40 70; + + /* Success text on a mint badge. Two near-identical greens meaning the same + thing were merged here, so the state reads consistently. */ + --text-success-rgb: 43 125 100; + + --shadow: 0 18px 55px rgb(var(--shadow-rgb) / 0.16); + --soft-shadow: 0 10px 30px rgb(var(--shadow-soft-rgb) / 0.11); + + /* The page wash. A token so dark mode can replace it outright rather than + fight it with an overlay. */ + --page-backdrop: + radial-gradient(circle at 18% 14%, rgb(var(--glow-pale-rgb) / 0.74), transparent 30%), + radial-gradient(circle at 84% 10%, rgb(var(--surface-violet-rgb) / 0.7), transparent 32%), + radial-gradient(circle at 75% 78%, rgb(var(--glint-rgb) / 0.26), transparent 28%), + linear-gradient( + 135deg, + rgb(var(--surface-tint-warm-rgb)) 0%, + rgb(var(--surface-tint-cool-rgb)) 44%, + rgb(var(--surface-tint-rgb)) 100% + ); --font-ice: 'Cinzel', 'Cormorant Garamond', Georgia, serif; --font-display: 'Cormorant Garamond', 'Iowan Old Style', 'Palatino Linotype', Georgia, serif; @@ -32,6 +184,205 @@ Didot, 'Bodoni 72', 'Bodoni 72 Smallcaps', 'Baskerville', 'Iowan Old Style', Georgia, serif; } +/* --------------------------------------------------------------------------- + Dark theme + --------------------------------------------------------------------------- + Only channels are overridden — no selector is restated, because every colour + in the app already resolves through one. The alphas stay at their call sites, + so a panel that was 72% white glass becomes 72% dark glass and keeps its + layering. + + Three groups move in opposite directions, which is why they were split apart + in the first place: + surfaces become dark (they are panel fills) + highlight stays light (bevels, glows, and text sitting on a coloured fill) + ink becomes light (it is text) + depth becomes black (a shadow on a dark page has to be darker, not paler) + + Brand hues are mostly left alone: mid-saturation cyans and blues read well on + navy. The ones adjusted below were either too dark to sit on the page as text + or too glaring as a large fill. + --------------------------------------------------------------------------- */ +@media (prefers-color-scheme: dark) { + :root:not([data-theme='light']) { + color-scheme: dark; + + /* Panels, lightest to deepest. Each sits above the page wash so cards lift + off it rather than dissolving into it. */ + --surface-rgb: 27 37 53; + --surface-tint-rgb: 32 44 62; + --surface-tint-cool-rgb: 29 41 58; + --surface-tint-warm-rgb: 35 47 65; + --surface-sky-rgb: 26 45 68; + --surface-mint-rgb: 21 46 43; + --surface-violet-rgb: 40 35 60; + --surface-blush-rgb: 56 31 34; + --surface-gold-rgb: 52 43 26; + + /* Softened off pure white: at the alphas the bevels use, #fff on navy reads + as a hard chalk line rather than a lit edge. */ + --highlight-rgb: 198 220 246; + --edge-rgb: 128 158 196; + + /* Text. Inverted through the ramp so the hierarchy survives: --ink is the + brightest, --faint the dimmest. */ + --ink-rgb: 228 237 249; + --soft-ink-rgb: 199 214 233; + --muted-rgb: 152 168 191; + --faint-rgb: 129 145 169; + --night-rgb: 224 234 247; + --ink-deep-rgb: 188 211 235; + --ink-abyss-rgb: 207 221 239; + --ink-slate-rgb: 192 210 232; + --ink-teal-rgb: 172 208 226; + --ink-navy-rgb: 178 204 231; + + /* Greys. Borders and secondary text both live here, so these land in the + middle: bright enough to read, dim enough not to outline every card. */ + --slate-rgb: 108 130 162; + --slate-soft-rgb: 102 126 160; + --slate-mid-rgb: 148 164 187; + --slate-light-rgb: 158 174 196; + --slate-deep-rgb: 138 156 180; + --slate-darkest-rgb: 168 184 205; + --slate-blue-rgb: 150 176 198; + --slate-steel-rgb: 154 180 200; + --slate-ocean-rgb: 158 184 206; + --slate-cool-rgb: 150 165 190; + --line-rgb: 108 138 176; + --line-strong-rgb: 96 158 194; + + /* Depth. Blue-grey shadows vanish on navy, so these go to near-black and + the app keeps its sense of elevation. */ + --shadow-rgb: 3 8 16; + --shadow-soft-rgb: 4 10 18; + --depth-rgb: 2 6 12; + + /* Text-only aliases: these lighten while their shadow/border twins darken. */ + --text-ocean-rgb: 156 186 208; + --text-steel-rgb: 160 184 222; + --text-accent-rgb: 96 196 236; + + --wordmark-ink-rgb: 214 233 250; + --wordmark-ink-soft-rgb: 150 196 226; + --text-success-rgb: 118 214 178; + --success-rgb: 96 200 162; + --aurora-deep-rgb: 92 198 166; + + /* Brand hues that needed a hand. The deep blues were unreadable as text on + navy; the pale washes were glaring at large sizes. */ + --accent-strong-rgb: 108 194 226; + --ocean-rgb: 138 194 218; + --ocean-deep-rgb: 128 186 212; + --marine-rgb: 118 180 210; + --glint-deep-rgb: 214 178 96; + --sky-pale-rgb: 96 148 186; + --sky-mist-rgb: 88 138 176; + --sky-muted-rgb: 96 142 174; + --sky-soft-rgb: 92 156 186; + --glow-pale-rgb: 120 196 226; + --aurora-rgb: 82 186 172; + --aurora-vivid-rgb: 62 190 172; + + /* The page. Deep navy with the same three coloured washes as the light + theme, pulled right down so they read as atmosphere, not as colour. */ + --page-backdrop: + radial-gradient(circle at 18% 14%, rgb(var(--accent-deep-rgb) / 0.2), transparent 34%), + radial-gradient(circle at 84% 10%, rgb(var(--prism-rgb) / 0.18), transparent 36%), + radial-gradient(circle at 75% 78%, rgb(var(--aurora-deep-rgb) / 0.12), transparent 30%), + linear-gradient(135deg, #060b14 0%, #0a1220 46%, #05090f 100%); + + --cloud: rgb(var(--surface-tint-warm-rgb)); + --aura: rgb(var(--surface-sky-rgb)); + --shadow: 0 18px 55px rgb(var(--shadow-rgb) / 0.6); + --soft-shadow: 0 10px 30px rgb(var(--shadow-soft-rgb) / 0.44); + } +} + +/* The Appearance setting stamps data-theme on the root element, and it has to + beat the media query in both directions — hence the :not() above and the + duplicated block here rather than a single shared selector list. Custom + properties do not cascade out of a media query into one, so this cannot be + expressed as `:root[data-theme='dark'], @media (...)`. */ +:root[data-theme='dark'] { + color-scheme: dark; + + --surface-rgb: 27 37 53; + --surface-tint-rgb: 32 44 62; + --surface-tint-cool-rgb: 29 41 58; + --surface-tint-warm-rgb: 35 47 65; + --surface-sky-rgb: 26 45 68; + --surface-mint-rgb: 21 46 43; + --surface-violet-rgb: 40 35 60; + --surface-blush-rgb: 56 31 34; + --surface-gold-rgb: 52 43 26; + + --highlight-rgb: 198 220 246; + --edge-rgb: 128 158 196; + + --ink-rgb: 228 237 249; + --soft-ink-rgb: 199 214 233; + --muted-rgb: 152 168 191; + --faint-rgb: 129 145 169; + --night-rgb: 224 234 247; + --ink-deep-rgb: 188 211 235; + --ink-abyss-rgb: 207 221 239; + --ink-slate-rgb: 192 210 232; + --ink-teal-rgb: 172 208 226; + --ink-navy-rgb: 178 204 231; + + --slate-rgb: 108 130 162; + --slate-soft-rgb: 102 126 160; + --slate-mid-rgb: 148 164 187; + --slate-light-rgb: 158 174 196; + --slate-deep-rgb: 138 156 180; + --slate-darkest-rgb: 168 184 205; + --slate-blue-rgb: 150 176 198; + --slate-steel-rgb: 154 180 200; + --slate-ocean-rgb: 158 184 206; + --slate-cool-rgb: 150 165 190; + --line-rgb: 108 138 176; + --line-strong-rgb: 96 158 194; + + --shadow-rgb: 3 8 16; + --shadow-soft-rgb: 4 10 18; + --depth-rgb: 2 6 12; + + --text-ocean-rgb: 156 186 208; + --text-steel-rgb: 160 184 222; + --text-accent-rgb: 96 196 236; + + --wordmark-ink-rgb: 214 233 250; + --wordmark-ink-soft-rgb: 150 196 226; + --text-success-rgb: 118 214 178; + --success-rgb: 96 200 162; + --aurora-deep-rgb: 92 198 166; + + --accent-strong-rgb: 108 194 226; + --ocean-rgb: 138 194 218; + --ocean-deep-rgb: 128 186 212; + --marine-rgb: 118 180 210; + --glint-deep-rgb: 214 178 96; + --sky-pale-rgb: 96 148 186; + --sky-mist-rgb: 88 138 176; + --sky-muted-rgb: 96 142 174; + --sky-soft-rgb: 92 156 186; + --glow-pale-rgb: 120 196 226; + --aurora-rgb: 82 186 172; + --aurora-vivid-rgb: 62 190 172; + + --page-backdrop: + radial-gradient(circle at 18% 14%, rgb(var(--accent-deep-rgb) / 0.2), transparent 34%), + radial-gradient(circle at 84% 10%, rgb(var(--prism-rgb) / 0.18), transparent 36%), + radial-gradient(circle at 75% 78%, rgb(var(--aurora-deep-rgb) / 0.12), transparent 30%), + linear-gradient(135deg, #060b14 0%, #0a1220 46%, #05090f 100%); + + --cloud: rgb(var(--surface-tint-warm-rgb)); + --aura: rgb(var(--surface-sky-rgb)); + --shadow: 0 18px 55px rgb(var(--shadow-rgb) / 0.6); + --soft-shadow: 0 10px 30px rgb(var(--shadow-soft-rgb) / 0.44); +} + html, body, #root { @@ -43,11 +394,7 @@ body, body { margin: 0; color: var(--ink); - background: - radial-gradient(circle at 18% 14%, rgba(204, 247, 255, 0.74), transparent 30%), - radial-gradient(circle at 84% 10%, rgba(235, 224, 255, 0.7), transparent 32%), - radial-gradient(circle at 75% 78%, rgba(255, 235, 179, 0.26), transparent 28%), - linear-gradient(135deg, #fbfdff 0%, #edfaff 44%, #f9fcff 100%); + background: var(--page-backdrop); user-select: none; } @@ -59,16 +406,16 @@ body { *::-webkit-scrollbar { width: 12px; height: 12px; - background: rgba(7, 22, 38, 0.14); + background: rgb(var(--ink-abyss-rgb) / 0.14); } *::-webkit-scrollbar-track { - border: 1px solid rgba(168, 232, 255, 0.22); + border: 1px solid rgb(var(--sky-pale-rgb) / 0.22); border-radius: 999px; background: - linear-gradient(180deg, rgba(255, 255, 255, 0.62), rgba(116, 205, 255, 0.2)), - rgba(7, 22, 38, 0.16); - box-shadow: inset 0 0 10px rgba(136, 221, 255, 0.12); + linear-gradient(180deg, rgb(var(--surface-rgb) / 0.62), rgba(116, 205, 255, 0.2)), + rgb(var(--ink-abyss-rgb) / 0.16); + box-shadow: inset 0 0 10px rgb(var(--accent-pale-rgb) / 0.12); } *::-webkit-scrollbar-thumb { @@ -78,13 +425,13 @@ body { background: linear-gradient( 180deg, - rgba(255, 255, 255, 0.96), - rgba(119, 220, 255, 0.82) 44%, + rgb(var(--surface-rgb) / 0.96), + rgb(var(--glow-rgb) / 0.82) 44%, rgba(28, 127, 190, 0.76) ), - rgba(122, 220, 255, 0.76); + rgb(var(--accent-pale-rgb) / 0.76); box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.86), + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.86), 0 0 14px rgba(83, 212, 255, 0.4); /* filter + box-shadow DO animate smoothly in WebKit, gradient backgrounds don't. Drive the hover response off those so it's a visible color/glow change. */ @@ -99,16 +446,16 @@ body { border-width: 2px; filter: saturate(1.3) brightness(1.12); box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.94), - 0 0 22px rgba(101, 226, 255, 0.75); + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.94), + 0 0 22px rgb(var(--glow-rgb) / 0.75); } *::-webkit-scrollbar-thumb:active { border-width: 1px; filter: saturate(1.5) brightness(1.22); box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 1), - 0 0 30px rgba(120, 232, 255, 0.9); + inset 0 1px 0 rgb(var(--highlight-rgb) / 1), + 0 0 30px rgb(var(--glow-rgb) / 0.9); } body::before { @@ -118,8 +465,8 @@ body::before { content: ''; opacity: 0.22; background-image: - radial-gradient(circle, rgba(77, 128, 197, 0.26) 0 1px, transparent 1.2px), - radial-gradient(circle, rgba(255, 255, 255, 0.9) 0 1px, transparent 1.4px); + radial-gradient(circle, rgb(var(--steel-vivid-rgb) / 0.26) 0 1px, transparent 1.2px), + radial-gradient(circle, rgb(var(--surface-rgb) / 0.9) 0 1px, transparent 1.4px); background-position: 0 0, 18px 22px; @@ -153,9 +500,9 @@ button:disabled { height: 100%; overflow: hidden; background: - radial-gradient(circle at 48% -8%, rgba(255, 255, 255, 0.9), transparent 28%), - radial-gradient(circle at 26% 94%, rgba(195, 249, 240, 0.34), transparent 34%), - radial-gradient(circle at 96% 62%, rgba(143, 114, 207, 0.12), transparent 24%); + radial-gradient(circle at 48% -8%, rgb(var(--surface-rgb) / 0.9), transparent 28%), + radial-gradient(circle at 26% 94%, rgb(var(--glow-pale-rgb) / 0.34), transparent 34%), + radial-gradient(circle at 96% 62%, rgb(var(--prism-rgb) / 0.12), transparent 24%); transition: grid-template-columns 600ms cubic-bezier(0.25, 1.25, 0.4, 1); } @@ -194,14 +541,14 @@ button:disabled { left: 3px; width: 2px; border-radius: 2px; - background: rgba(75, 163, 206, 0); + background: rgb(var(--accent-mid-rgb) / 0); transition: background 160ms ease; } .panel-resize-handle:hover::after, .panel-resize-handle:focus-visible::after, .aether-shell.is-resizing .panel-resize-handle::after { - background: rgba(75, 163, 206, 0.5); + background: rgb(var(--accent-mid-rgb) / 0.5); } .panel-resize-handle:focus-visible { @@ -229,15 +576,15 @@ button:disabled { align-items: center; height: var(--titlebar-height); padding: 0 18px; - border-bottom: 1px solid rgba(143, 165, 196, 0.12); - background: rgba(255, 255, 255, 0.44); + border-bottom: 1px solid rgb(var(--slate-rgb) / 0.12); + background: rgb(var(--surface-rgb) / 0.44); backdrop-filter: blur(20px) saturate(1.18); -webkit-app-region: drag; } .window-titlebar strong { justify-self: center; - color: rgba(23, 36, 58, 0.82); + color: rgb(var(--ink-rgb) / 0.82); font-family: var(--font-old); font-size: 14px; font-weight: 600; @@ -250,7 +597,7 @@ button:disabled { pointer-events: none; content: ''; background: - linear-gradient(90deg, transparent 0 34%, rgba(255, 255, 255, 0.36) 50%, transparent 70%), + linear-gradient(90deg, transparent 0 34%, rgb(var(--surface-rgb) / 0.36) 50%, transparent 70%), radial-gradient(circle at 72% 34%, rgba(117, 167, 232, 0.16), transparent 18%); mix-blend-mode: screen; } @@ -265,9 +612,9 @@ button:disabled { height: 100%; /* Bottom padding reserves room for the absolutely-pinned settings button. */ padding: calc(var(--titlebar-height) + 28px) 10px 86px; - border-right: 1px solid rgba(143, 165, 196, 0.18); - background: rgba(255, 255, 255, 0.48); - box-shadow: 12px 0 38px rgba(105, 137, 181, 0.08); + border-right: 1px solid rgb(var(--slate-rgb) / 0.18); + background: rgb(var(--surface-rgb) / 0.48); + box-shadow: 12px 0 38px rgb(var(--steel-mid-rgb) / 0.08); backdrop-filter: blur(26px) saturate(1.3); } @@ -275,10 +622,10 @@ button:disabled { .app-button { display: grid; place-items: center; - border: 1px solid rgba(125, 158, 205, 0.24); + border: 1px solid rgb(var(--slate-soft-rgb) / 0.24); border-radius: 18px; padding: 0; - color: #5f88c7; + color: rgb(var(--text-steel-rgb)); line-height: 0; box-shadow: var(--soft-shadow); transition: @@ -317,8 +664,8 @@ button:disabled { width: 52px; height: 52px; background: - radial-gradient(circle at 44% 28%, rgba(255, 255, 255, 0.96), transparent 42%), - linear-gradient(145deg, rgba(255, 255, 255, 0.88), rgba(219, 246, 255, 0.74)); + radial-gradient(circle at 44% 28%, rgb(var(--surface-rgb) / 0.96), transparent 42%), + linear-gradient(145deg, rgb(var(--surface-rgb) / 0.88), rgb(var(--surface-sky-rgb) / 0.74)); overflow: visible; } @@ -328,7 +675,7 @@ button:disabled { width: 47px; height: 47px; object-fit: contain; - filter: drop-shadow(0 8px 16px rgba(65, 140, 174, 0.2)); + filter: drop-shadow(0 8px 16px rgb(var(--shadow-soft-rgb) / 0.2)); } .brand-mark > svg { @@ -370,15 +717,15 @@ button:disabled { .brand-mark:hover, .app-button:hover, .app-button.active { - border-color: rgba(72, 125, 205, 0.38); + border-color: rgb(var(--steel-vivid-rgb) / 0.38); background: - radial-gradient(circle at 30% 20%, #ffffff, transparent 45%), - radial-gradient(circle at 72% 82%, rgba(98, 216, 198, 0.32), transparent 42%), - linear-gradient(145deg, #effcff, #ffffff); + radial-gradient(circle at 30% 20%, rgb(var(--surface-rgb)), transparent 45%), + radial-gradient(circle at 72% 82%, rgb(var(--aurora-rgb) / 0.32), transparent 42%), + linear-gradient(145deg, rgb(var(--surface-tint-rgb)), rgb(var(--surface-rgb))); color: var(--accent-strong); box-shadow: - 0 16px 36px rgba(62, 132, 163, 0.2), - 0 0 0 5px rgba(126, 215, 237, 0.08); + 0 16px 36px rgb(var(--shadow-soft-rgb) / 0.2), + 0 0 0 5px rgb(var(--aqua-rgb) / 0.08); transform: translateY(-1px); } @@ -398,8 +745,8 @@ button:disabled { height: 50px; margin: 0 auto; background: - radial-gradient(circle at 42% 32%, rgba(255, 255, 255, 0.98), transparent 42%), - linear-gradient(145deg, #ffffff, #dbeeff); + radial-gradient(circle at 42% 32%, rgb(var(--surface-rgb) / 0.98), transparent 42%), + linear-gradient(145deg, rgb(var(--surface-rgb)), rgb(var(--surface-sky-rgb))); } /* Pin the settings button to the bottom-left of the rail so it stays put @@ -412,7 +759,7 @@ button:disabled { width: 50px; height: 50px; margin: 0 auto; - color: #6f82a0; + color: rgb(var(--muted-rgb)); } .tooltip-host { @@ -435,25 +782,25 @@ button:disabled { content: attr(data-tooltip); min-width: max-content; max-width: 180px; - border: 1px solid rgba(130, 163, 207, 0.3); + border: 1px solid rgb(var(--slate-soft-rgb) / 0.3); border-radius: 11px; padding: 4px 8px; - color: #3d5b84; + color: rgb(var(--slate-steel-rgb)); font-size: 11px; font-weight: 760; line-height: 1.2; background: - radial-gradient(circle at 35% 26%, rgba(255, 255, 255, 0.98), transparent 45%), - linear-gradient(145deg, #ffffff, #e8f2ff); - box-shadow: 0 10px 24px rgba(82, 126, 188, 0.18); + radial-gradient(circle at 35% 26%, rgb(var(--surface-rgb) / 0.98), transparent 45%), + linear-gradient(145deg, rgb(var(--surface-rgb)), rgb(var(--surface-sky-rgb))); + box-shadow: 0 10px 24px rgb(var(--steel-mid-rgb) / 0.18); } .tooltip-host::before { width: 8px; height: 8px; - border-left: 1px solid rgba(130, 163, 207, 0.3); - border-bottom: 1px solid rgba(130, 163, 207, 0.3); - background: linear-gradient(145deg, #ffffff, #edf5ff); + border-left: 1px solid rgb(var(--slate-soft-rgb) / 0.3); + border-bottom: 1px solid rgb(var(--slate-soft-rgb) / 0.3); + background: linear-gradient(145deg, rgb(var(--surface-rgb)), rgb(var(--surface-tint-rgb))); } .tooltip-host:not([data-tooltip])::after, @@ -528,7 +875,7 @@ button:disabled { height: 28px; border-radius: 6px; background: linear-gradient(#95c3ff, #5b91e1); - box-shadow: 0 0 18px rgba(85, 137, 217, 0.45); + box-shadow: 0 0 18px rgb(var(--steel-rgb) / 0.45); } .app-button.active .app-dot { @@ -553,8 +900,8 @@ button:disabled { pointer-events: none; content: ''; background: - radial-gradient(circle at 18% 20%, rgba(255, 255, 255, 0.9), transparent 19%), - radial-gradient(circle at 88% 78%, rgba(207, 229, 255, 0.48), transparent 28%); + radial-gradient(circle at 18% 20%, rgb(var(--surface-rgb) / 0.9), transparent 19%), + radial-gradient(circle at 88% 78%, rgb(var(--sky-pale-rgb) / 0.48), transparent 28%); } .workspace.dashboard-open::before { @@ -573,8 +920,8 @@ button:disabled { gap: 10px; height: var(--nav-height); padding: 10px 16px; - border-bottom: 1px solid rgba(143, 165, 196, 0.22); - background: rgba(255, 255, 255, 0.58); + border-bottom: 1px solid rgb(var(--slate-rgb) / 0.22); + background: rgb(var(--surface-rgb) / 0.58); backdrop-filter: blur(24px) saturate(1.35); } @@ -589,11 +936,11 @@ button:disabled { width: 36px; height: 36px; padding: 0; - border-color: rgba(125, 158, 205, 0.24); + border-color: rgb(var(--slate-soft-rgb) / 0.24); background: - radial-gradient(circle at 35% 24%, rgba(255, 255, 255, 0.96), transparent 44%), - linear-gradient(145deg, #f2f8ff, #d9eaff); - color: #5f88c7; + radial-gradient(circle at 35% 24%, rgb(var(--surface-rgb) / 0.96), transparent 44%), + linear-gradient(145deg, rgb(var(--surface-tint-rgb)), rgb(var(--surface-sky-rgb))); + color: rgb(var(--text-steel-rgb)); } .history-controls svg { @@ -629,10 +976,10 @@ textarea { width: 100%; border: 1px solid var(--line); border-radius: 10px; - background: rgba(255, 255, 255, 0.82); + background: rgb(var(--surface-rgb) / 0.82); color: var(--ink); outline: none; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.8); + box-shadow: inset 0 1px 0 rgb(var(--highlight-rgb) / 0.8); } input, @@ -646,10 +993,10 @@ textarea, input:focus, select:focus, textarea:focus { - border-color: rgba(78, 134, 215, 0.48); + border-color: rgb(var(--steel-rgb) / 0.48); box-shadow: 0 0 0 4px rgba(106, 159, 233, 0.14), - inset 0 1px 0 rgba(255, 255, 255, 0.9); + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.9); } .address-bar input { @@ -660,14 +1007,14 @@ textarea:focus { button { height: 36px; - border: 1px solid rgba(104, 140, 190, 0.24); + border: 1px solid rgb(var(--steel-mid-rgb) / 0.24); border-radius: 10px; padding: 0 14px; - background: rgba(255, 255, 255, 0.78); + background: rgb(var(--surface-rgb) / 0.78); color: var(--soft-ink); font-size: 13px; font-weight: 760; - box-shadow: 0 8px 22px rgba(94, 132, 185, 0.08); + box-shadow: 0 8px 22px rgb(var(--steel-mid-rgb) / 0.08); } .address-bar button, @@ -676,16 +1023,16 @@ button { .semantic-trail-form button, .new-collection-button, .save-page-button { - border-color: rgba(67, 128, 182, 0.34); + border-color: rgb(var(--azure-rgb) / 0.34); background: - linear-gradient(180deg, rgba(255, 255, 255, 0.24), transparent 44%), - linear-gradient(145deg, #dff3ff, #76b8df 52%, #3f7fbd); - color: #ffffff; + linear-gradient(180deg, rgb(var(--surface-rgb) / 0.24), transparent 44%), + linear-gradient(145deg, rgb(var(--surface-sky-rgb)), rgb(var(--accent-light-rgb)) 52%, rgb(var(--azure-rgb))); + color: rgb(var(--highlight-rgb)); box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.5), - inset 0 -1px 0 rgba(28, 89, 142, 0.18), - 0 9px 18px rgba(47, 115, 172, 0.14); - text-shadow: 0 1px 2px rgba(26, 70, 112, 0.22); + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.5), + inset 0 -1px 0 rgb(var(--ocean-deep-rgb) / 0.18), + 0 9px 18px rgb(var(--azure-rgb) / 0.14); + text-shadow: 0 1px 2px rgb(var(--ink-navy-rgb) / 0.22); font-family: var(--font-ice); font-weight: 600; font-size: 15px; @@ -697,14 +1044,14 @@ button { .semantic-trail-form button:hover, .new-collection-button:hover, .save-page-button:hover { - border-color: rgba(54, 122, 178, 0.42); + border-color: rgb(var(--azure-rgb) / 0.42); background: - linear-gradient(180deg, rgba(255, 255, 255, 0.32), transparent 46%), - linear-gradient(145deg, #e8f7ff, #6fb5dd 52%, #3678b7); + linear-gradient(180deg, rgb(var(--surface-rgb) / 0.32), transparent 46%), + linear-gradient(145deg, rgb(var(--surface-tint-cool-rgb)), rgb(var(--accent-light-rgb)) 52%, rgb(var(--azure-rgb))); box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.58), - inset 0 -1px 0 rgba(28, 89, 142, 0.16), - 0 11px 22px rgba(47, 115, 172, 0.18); + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.58), + inset 0 -1px 0 rgb(var(--ocean-deep-rgb) / 0.16), + 0 11px 22px rgb(var(--azure-rgb) / 0.18); } .save-page-button { @@ -724,23 +1071,23 @@ button { } .address-bar .history-controls button { - border-color: rgba(125, 158, 205, 0.24); + border-color: rgb(var(--slate-soft-rgb) / 0.24); background: - radial-gradient(circle at 35% 24%, rgba(255, 255, 255, 0.96), transparent 44%), - linear-gradient(145deg, #f2f8ff, #d9eaff); - color: #5f88c7; + radial-gradient(circle at 35% 24%, rgb(var(--surface-rgb) / 0.96), transparent 44%), + linear-gradient(145deg, rgb(var(--surface-tint-rgb)), rgb(var(--surface-sky-rgb))); + color: rgb(var(--text-steel-rgb)); } .address-bar .history-controls button:hover { background: - radial-gradient(circle at 35% 24%, #ffffff, transparent 44%), - linear-gradient(145deg, #eaf4ff, #cfe5ff); + radial-gradient(circle at 35% 24%, rgb(var(--surface-rgb)), transparent 44%), + linear-gradient(145deg, rgb(var(--surface-tint-cool-rgb)), rgb(var(--sky-pale-rgb))); color: var(--accent-strong); } .danger-button { - border-color: rgba(184, 91, 89, 0.2); - background: rgba(255, 255, 255, 0.72); + border-color: rgb(var(--danger-rgb) / 0.2); + background: rgb(var(--surface-rgb) / 0.72); color: var(--danger); } @@ -749,7 +1096,7 @@ button { inset: var(--top-bar-height) 0 0; display: grid; place-items: center; - color: rgba(93, 118, 154, 0.62); + color: rgb(var(--slate-blue-rgb) / 0.62); font-size: 12px; font-weight: 760; letter-spacing: 0.1em; diff --git a/src/renderer/src/assets/styles/intelligence-panel.css b/src/renderer/src/assets/styles/intelligence-panel.css index f0463cf..a309b7a 100644 --- a/src/renderer/src/assets/styles/intelligence-panel.css +++ b/src/renderer/src/assets/styles/intelligence-panel.css @@ -4,13 +4,13 @@ height: 100%; overflow-x: hidden; overflow-y: auto; - border-left: 1px solid rgba(143, 165, 196, 0.22); - background: rgba(255, 255, 255, 0.48); - box-shadow: -16px 0 46px rgba(104, 137, 184, 0.08); + border-left: 1px solid rgb(var(--slate-rgb) / 0.22); + background: rgb(var(--surface-rgb) / 0.48); + box-shadow: -16px 0 46px rgb(var(--steel-mid-rgb) / 0.08); backdrop-filter: blur(28px) saturate(1.34); overscroll-behavior: contain; scroll-padding-bottom: 28px; - scrollbar-color: rgba(82, 178, 219, 0.38) transparent; + scrollbar-color: rgb(var(--accent-bright-rgb) / 0.38) transparent; scrollbar-width: thin; transition: background 420ms ease, @@ -29,19 +29,19 @@ .intelligence-panel::-webkit-scrollbar-thumb { border-radius: 999px; background: - linear-gradient(180deg, rgba(255, 255, 255, 0.74), rgba(255, 255, 255, 0.22)), - rgba(82, 178, 219, 0.38); + linear-gradient(180deg, rgb(var(--surface-rgb) / 0.74), rgb(var(--surface-rgb) / 0.22)), + rgb(var(--accent-bright-rgb) / 0.38); } .intelligence-panel.collapsed { width: var(--panel-collapsed-width); z-index: 26; overflow: hidden; - border-left-color: rgba(204, 238, 247, 0.42); - background: rgba(255, 255, 255, 0.28); + border-left-color: rgb(var(--glow-pale-rgb) / 0.42); + background: rgb(var(--surface-rgb) / 0.28); box-shadow: - -12px 0 42px rgba(116, 196, 224, 0.12), - inset 1px 0 0 rgba(255, 255, 255, 0.52); + -12px 0 42px rgb(var(--accent-light-rgb) / 0.12), + inset 1px 0 0 rgb(var(--highlight-rgb) / 0.52); } .intelligence-panel::before, @@ -58,17 +58,17 @@ .intelligence-panel::before { background: - radial-gradient(circle at 50% 18%, rgba(255, 255, 255, 0.86), transparent 20%), - radial-gradient(circle at 28% 46%, rgba(188, 245, 255, 0.42), transparent 32%), - linear-gradient(180deg, rgba(255, 255, 255, 0.16), rgba(220, 249, 255, 0.18)); + radial-gradient(circle at 50% 18%, rgb(var(--surface-rgb) / 0.86), transparent 20%), + radial-gradient(circle at 28% 46%, rgb(var(--glow-pale-rgb) / 0.42), transparent 32%), + linear-gradient(180deg, rgb(var(--surface-rgb) / 0.16), rgb(var(--surface-tint-cool-rgb) / 0.18)); filter: blur(10px); transform: translateX(20px) scaleX(0.9); } .intelligence-panel::after { - background-image: radial-gradient(circle, rgba(255, 255, 255, 0.85) 0 1px, transparent 1.5px); + background-image: radial-gradient(circle, rgb(var(--surface-rgb) / 0.85) 0 1px, transparent 1.5px); background-size: 42px 42px; - mask-image: linear-gradient(to bottom, transparent 0%, #000 18%, transparent 94%); + mask-image: linear-gradient(to bottom, transparent 0%, rgb(var(--mask-rgb)) 18%, transparent 94%); transform: translateY(18px); } @@ -127,24 +127,24 @@ align-items: center; height: 26px; padding: 0 10px; - border: 1px solid rgba(105, 139, 190, 0.18); + border: 1px solid rgb(var(--steel-mid-rgb) / 0.18); border-radius: 999px; font-size: 11px; font-weight: 800; } .status-pill.online { - background: rgba(43, 146, 112, 0.1); + background: rgb(var(--success-rgb) / 0.1); color: var(--success); } .status-pill.offline { - background: rgba(184, 91, 89, 0.1); + background: rgb(var(--danger-rgb) / 0.1); color: var(--danger); } .status-pill.neutral { - background: rgba(255, 255, 255, 0.72); + background: rgb(var(--surface-rgb) / 0.72); color: var(--muted); } @@ -155,14 +155,15 @@ right: calc(var(--panel-width) + 18px); z-index: 80; max-width: min(420px, calc(100vw - 140px)); - align-items: center; + /* Top-aligned so the dot stays level with the first line once text wraps. */ + align-items: flex-start; gap: 10px; - border: 1px solid rgba(111, 157, 209, 0.24); + border: 1px solid rgb(var(--steel-soft-rgb) / 0.24); border-radius: 10px; padding: 10px 13px; - background: rgba(248, 252, 255, 0.94); + background: rgb(var(--surface-tint-warm-rgb) / 0.94); color: var(--ink); - box-shadow: 0 16px 44px rgba(64, 105, 146, 0.18); + box-shadow: 0 16px 44px rgb(var(--shadow-rgb) / 0.18); backdrop-filter: blur(18px); } @@ -170,18 +171,25 @@ width: 9px; height: 9px; flex: 0 0 auto; + /* Optical centring against the first line of 12px/1.35 text. */ + margin-top: 4px; border-radius: 999px; background: var(--accent-strong); box-shadow: 0 0 0 5px rgba(75, 158, 215, 0.13); } +/* Wraps to three lines before clamping. Backend errors name paths and models and run + well past one line; ellipsing them at the first line hid the part that identifies + the problem. Short status text is unaffected — it never reaches the clamp. */ .status-toast strong { + display: -webkit-box; overflow: hidden; + -webkit-box-orient: vertical; + -webkit-line-clamp: 3; font-size: 12px; font-weight: 820; line-height: 1.35; - text-overflow: ellipsis; - white-space: nowrap; + overflow-wrap: anywhere; } .status-toast.info span { @@ -190,16 +198,16 @@ .status-toast.success span { background: var(--success); - box-shadow: 0 0 0 5px rgba(42, 148, 111, 0.13); + box-shadow: 0 0 0 5px rgb(var(--success-rgb) / 0.13); } .status-toast.error { - border-color: rgba(184, 91, 89, 0.28); + border-color: rgb(var(--danger-rgb) / 0.28); } .status-toast.error span { background: var(--danger); - box-shadow: 0 0 0 5px rgba(184, 91, 89, 0.13); + box-shadow: 0 0 0 5px rgb(var(--danger-rgb) / 0.13); } .aether-shell.panel-collapsed .status-toast { @@ -221,12 +229,12 @@ border-radius: 12px; padding: 6px 8px 6px 10px; background: - radial-gradient(circle at 18% 0%, rgba(255, 255, 255, 0.98), transparent 42%), - linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(226, 246, 255, 0.96)), - rgba(255, 255, 255, 0.96); + radial-gradient(circle at 18% 0%, rgb(var(--surface-rgb) / 0.98), transparent 42%), + linear-gradient(135deg, rgb(var(--surface-rgb) / 0.98), rgb(var(--surface-tint-cool-rgb) / 0.96)), + rgb(var(--surface-rgb) / 0.96); box-shadow: 0 0 0 4px rgba(67, 158, 221, 0.12), - 0 18px 48px rgba(82, 125, 176, 0.24); + 0 18px 48px rgb(var(--steel-deep-rgb) / 0.24); backdrop-filter: blur(18px); } @@ -246,7 +254,7 @@ border: 0; padding: 0 2px; background: transparent; - color: #18324b; + color: rgb(var(--ink-slate-rgb)); font: inherit; font-size: 13px; font-weight: 720; @@ -254,7 +262,7 @@ } .find-bar input::placeholder { - color: rgba(86, 107, 134, 0.68); + color: rgb(var(--slate-ocean-rgb) / 0.68); } .find-count { @@ -263,7 +271,7 @@ font-size: 12px; font-weight: 720; font-variant-numeric: tabular-nums; - color: #56708c; + color: rgb(var(--slate-ocean-rgb)); white-space: nowrap; } @@ -283,11 +291,11 @@ justify-content: center; width: 28px; height: 28px; - border: 1px solid rgba(116, 143, 180, 0.18); + border: 1px solid rgb(var(--slate-cool-rgb) / 0.18); border-radius: 9px; padding: 0; - background: rgba(255, 255, 255, 0.62); - color: #6d7f99; + background: rgb(var(--surface-rgb) / 0.62); + color: rgb(var(--muted-rgb)); line-height: 1; box-shadow: none; cursor: pointer; @@ -299,8 +307,8 @@ } .find-nav-button:hover:not(:disabled) { - border-color: rgba(67, 145, 204, 0.28); - background: rgba(232, 247, 255, 0.9); + border-color: rgb(var(--sky-deep-rgb) / 0.28); + background: rgb(var(--surface-tint-cool-rgb) / 0.9); color: var(--accent-strong); } @@ -312,11 +320,11 @@ .find-close-button { width: 28px; height: 28px; - border: 1px solid rgba(116, 143, 180, 0.18); + border: 1px solid rgb(var(--slate-cool-rgb) / 0.18); border-radius: 9px; padding: 0; - background: rgba(255, 255, 255, 0.62); - color: #6d7f99; + background: rgb(var(--surface-rgb) / 0.62); + color: rgb(var(--muted-rgb)); font-size: 19px; font-weight: 540; line-height: 1; @@ -324,8 +332,8 @@ } .find-close-button:hover { - border-color: rgba(67, 145, 204, 0.28); - background: rgba(232, 247, 255, 0.9); + border-color: rgb(var(--sky-deep-rgb) / 0.28); + background: rgb(var(--surface-tint-cool-rgb) / 0.9); color: var(--accent-strong); } @@ -401,15 +409,15 @@ height: 24px; place-items: center; margin-left: auto; - border: 1px solid rgba(90, 185, 224, 0.18); + border: 1px solid rgb(var(--accent-rgb) / 0.18); border-radius: 999px; color: var(--accent-strong); background: - radial-gradient(circle at 35% 22%, rgba(255, 255, 255, 0.9), transparent 42%), - linear-gradient(180deg, rgba(235, 251, 255, 0.86), rgba(182, 234, 250, 0.42)); + radial-gradient(circle at 35% 22%, rgb(var(--surface-rgb) / 0.9), transparent 42%), + linear-gradient(180deg, rgb(var(--surface-tint-rgb) / 0.86), rgb(var(--sky-pale-rgb) / 0.42)); box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.86), - 0 6px 16px rgba(64, 154, 205, 0.12); + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.86), + 0 6px 16px rgb(var(--sky-deep-rgb) / 0.12); } .flow-heading-icon svg { @@ -485,8 +493,8 @@ width: 99%; padding: 24px 8px; border-radius: 12px; - border-color: rgba(133, 158, 193, 0.16); - background: rgba(255, 255, 255, 0.42); + border-color: rgb(var(--slate-soft-rgb) / 0.16); + background: rgb(var(--surface-rgb) / 0.42); margin-top: 2px; text-align: left; transition: @@ -506,8 +514,8 @@ width: 38px; height: 38px; border-radius: 9px; - border: 1px solid rgba(133, 158, 193, 0.16); - background: rgba(255, 255, 255, 0.6); + border: 1px solid rgb(var(--slate-soft-rgb) / 0.16); + background: rgb(var(--surface-rgb) / 0.6); } .ask-current-text { @@ -540,7 +548,7 @@ width: 18px; height: 18px; border-radius: 50%; - border: 2px solid rgba(133, 158, 193, 0.45); + border: 2px solid rgb(var(--slate-soft-rgb) / 0.45); transition: border-color 0.16s ease; } @@ -558,13 +566,13 @@ .ask-current-button.active, .ask-current-button:hover:not(:disabled) { - border-color: rgba(79, 174, 202, 0.28); - background: rgba(238, 250, 255, 0.78); + border-color: rgb(var(--accent-muted-rgb) / 0.28); + background: rgb(var(--surface-tint-rgb) / 0.78); } .ask-current-button.active .ask-current-badge { - border-color: rgba(79, 174, 202, 0.28); - background: rgba(255, 255, 255, 0.82); + border-color: rgb(var(--accent-muted-rgb) / 0.28); + background: rgb(var(--surface-rgb) / 0.82); } .ask-current-button.active .ask-current-text strong { @@ -581,7 +589,7 @@ width: 24px; height: 24px; border-radius: 8px; - background: rgba(255, 255, 255, 0.72); + background: rgb(var(--surface-rgb) / 0.72); } .ask-current-default svg { @@ -612,25 +620,25 @@ align-content: center; justify-items: center; gap: 2px; - border-color: rgba(133, 158, 193, 0.16); + border-color: rgb(var(--slate-soft-rgb) / 0.16); border-radius: 8px; padding: 25px 4px; - background: rgba(255, 255, 255, 0.42); + background: rgb(var(--surface-rgb) / 0.42); text-align: center; } .ask-hub-picker button.active { border-color: var(--prism) !important; background: - radial-gradient(circle at 16% 20%, rgba(255, 255, 255, 0.95), transparent 36%), - rgba(238, 250, 255, 0.78); + radial-gradient(circle at 16% 20%, rgb(var(--surface-rgb) / 0.95), transparent 36%), + rgb(var(--surface-tint-rgb) / 0.78); color: purple !important; } .ask-hub-picker button:hover { - border-color: rgba(79, 174, 202, 0.3); + border-color: rgb(var(--accent-muted-rgb) / 0.3); background: - radial-gradient(circle at 16% 20%, rgba(255, 255, 255, 0.95), transparent 36%), - rgba(238, 250, 255, 0.78); + radial-gradient(circle at 16% 20%, rgb(var(--surface-rgb) / 0.95), transparent 36%), + rgb(var(--surface-tint-rgb) / 0.78); color: var(--accent-strong); } @@ -640,7 +648,7 @@ width: 28px; height: 28px; border-radius: 8px; - background: rgba(255, 255, 255, 0.76); + background: rgb(var(--surface-rgb) / 0.76); } .ask-hub-picker svg { @@ -697,7 +705,7 @@ display: grid; gap: 6px; padding-left: 9px; - border-left: 2px solid rgba(75, 163, 206, 0.28); + border-left: 2px solid rgb(var(--accent-mid-rgb) / 0.28); } .thread-turn-prompt { @@ -705,7 +713,7 @@ font-size: 12px; font-weight: 700; line-height: 1.4; - color: rgba(30, 79, 112, 0.92); + color: rgb(var(--ink-navy-rgb) / 0.92); } /* Prior answers sit back a step: same card, lower emphasis. */ @@ -719,19 +727,19 @@ .thread-clear-button { padding: 3px 10px; - border: 1px solid rgba(75, 163, 206, 0.32); + border: 1px solid rgb(var(--accent-mid-rgb) / 0.32); border-radius: 999px; - background: rgba(255, 255, 255, 0.82); + background: rgb(var(--surface-rgb) / 0.82); font: inherit; font-size: 10.5px; font-weight: 680; - color: rgba(30, 79, 112, 0.86); + color: rgb(var(--ink-navy-rgb) / 0.86); cursor: pointer; } .thread-clear-button:hover { - border-color: rgba(75, 163, 206, 0.6); - background: rgba(255, 255, 255, 1); + border-color: rgb(var(--accent-mid-rgb) / 0.6); + background: rgb(var(--surface-rgb) / 1); } @media (prefers-reduced-motion: reduce) { @@ -747,11 +755,11 @@ padding: 7px 10px; border: 1px solid rgba(214, 178, 96, 0.36); border-radius: 9px; - background: rgba(250, 240, 214, 0.42); + background: rgb(var(--surface-gold-rgb) / 0.42); font-size: 11px; font-weight: 620; line-height: 1.4; - color: #6d4c15; + color: rgb(var(--glint-deep-rgb)); } .chat-form textarea { @@ -809,7 +817,7 @@ .semantic-trail-help { margin-top: -2px; - color: #66809a; + color: rgb(var(--muted-rgb)); font-size: 10px; font-weight: 650; line-height: 1.35; @@ -823,18 +831,18 @@ display: grid; gap: 3px; margin-bottom: 10px; - border: 1px solid rgba(139, 190, 215, 0.18); + border: 1px solid rgb(var(--sky-muted-rgb) / 0.18); border-radius: 12px; padding: 10px 11px; background: - radial-gradient(circle at 12% 0%, rgba(255, 255, 255, 0.88), transparent 42%), - rgba(244, 251, 255, 0.58); - color: #61758d; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72); + radial-gradient(circle at 12% 0%, rgb(var(--surface-rgb) / 0.88), transparent 42%), + rgb(var(--surface-tint-warm-rgb) / 0.58); + color: rgb(var(--slate-mid-rgb)); + box-shadow: inset 0 1px 0 rgb(var(--highlight-rgb) / 0.72); } .semantic-trail-description strong { - color: #1f344d; + color: rgb(var(--ink-slate-rgb)); font-size: 11px; font-weight: 860; } @@ -848,33 +856,33 @@ .semantic-trail-form input { width: 100%; height: 34px; - border: 1px solid rgba(126, 161, 198, 0.18); + border: 1px solid rgb(var(--slate-soft-rgb) / 0.18); border-radius: 10px; padding: 0 10px; - background: rgba(255, 255, 255, 0.68); + background: rgb(var(--surface-rgb) / 0.68); color: var(--ink); font: inherit; font-size: 12px; font-weight: 720; outline: none; - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.72); + box-shadow: inset 0 1px 0 rgb(var(--highlight-rgb) / 0.72); } .semantic-trail-form input:focus { - border-color: rgba(64, 154, 205, 0.42); - background: rgba(250, 254, 255, 0.94); + border-color: rgb(var(--accent-mid-rgb) / 0.42); + background: rgb(var(--surface-tint-warm-rgb) / 0.94); } .semantic-trail-card, .semantic-trail-loading, .semantic-trail-empty { - border: 1px solid rgba(139, 190, 215, 0.24); + border: 1px solid rgb(var(--sky-muted-rgb) / 0.24); border-radius: 12px; background: - radial-gradient(circle at 22% 4%, rgba(255, 255, 255, 0.9), transparent 34%), - rgba(245, 250, 255, 0.76); + radial-gradient(circle at 22% 4%, rgb(var(--surface-rgb) / 0.9), transparent 34%), + rgb(var(--surface-tint-warm-rgb) / 0.76); box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.78), + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.78), 0 12px 32px rgba(84, 160, 199, 0.1); } @@ -891,7 +899,7 @@ border: 1px solid rgba(119, 168, 205, 0.12); border-radius: 10px; padding: 9px; - background: rgba(255, 255, 255, 0.42); + background: rgb(var(--surface-rgb) / 0.42); } .semantic-trail-root div { @@ -928,7 +936,7 @@ display: -webkit-box; margin: 0; overflow: hidden; - color: rgba(60, 80, 105, 0.74); + color: rgb(var(--soft-ink-rgb) / 0.74); font-size: 10.5px; line-height: 1.42; -webkit-box-orient: vertical; @@ -959,13 +967,13 @@ pointer-events: none; content: ''; background: - radial-gradient(60% 22% at 50% 0%, rgba(255, 255, 255, 0.42), transparent 72%), + radial-gradient(60% 22% at 50% 0%, rgb(var(--surface-rgb) / 0.42), transparent 72%), linear-gradient( 180deg, transparent 0, - rgba(150, 216, 247, 0.3) 14%, - rgba(92, 186, 228, 0.32) 52%, - rgba(150, 216, 247, 0.3) 86%, + rgb(var(--accent-pale-rgb) / 0.3) 14%, + rgb(var(--accent-light-rgb) / 0.32) 52%, + rgb(var(--accent-pale-rgb) / 0.3) 86%, transparent 100% ); filter: blur(7px); @@ -986,17 +994,17 @@ 180deg, transparent 0, transparent 30px, - rgba(255, 255, 255, 0.16) 44px, - rgba(255, 255, 255, 0.62) 52px, - rgba(255, 255, 255, 0.16) 60px, + rgb(var(--surface-rgb) / 0.16) 44px, + rgb(var(--surface-rgb) / 0.62) 52px, + rgb(var(--surface-rgb) / 0.16) 60px, transparent 74px, transparent 96px ); background-size: 100% 96px; filter: blur(1.4px); opacity: 0.7; - -webkit-mask-image: linear-gradient(180deg, transparent, #000 12%, #000 88%, transparent); - mask-image: linear-gradient(180deg, transparent, #000 12%, #000 88%, transparent); + -webkit-mask-image: linear-gradient(180deg, transparent, rgb(var(--mask-rgb)) 12%, rgb(var(--mask-rgb)) 88%, transparent); + mask-image: linear-gradient(180deg, transparent, rgb(var(--mask-rgb)) 12%, rgb(var(--mask-rgb)) 88%, transparent); animation: flowStream 3.6s linear infinite; } @@ -1011,12 +1019,12 @@ height: auto !important; min-height: 124px; min-width: 0; - border: 1px solid rgba(126, 161, 198, 0.16); + border: 1px solid rgb(var(--slate-soft-rgb) / 0.16); border-radius: 14px; padding: 13px; background: - radial-gradient(circle at 12% 12%, rgba(255, 255, 255, 0.92), transparent 34%), - rgba(255, 255, 255, 0.62); + radial-gradient(circle at 12% 12%, rgb(var(--surface-rgb) / 0.92), transparent 34%), + rgb(var(--surface-rgb) / 0.62); color: var(--ink); line-height: normal; text-align: left; @@ -1060,10 +1068,10 @@ } .semantic-trail-item:hover { - border-color: rgba(64, 154, 205, 0.28); + border-color: rgb(var(--accent-mid-rgb) / 0.28); background: - radial-gradient(circle at 12% 12%, rgba(255, 255, 255, 0.98), transparent 34%), - rgba(235, 249, 255, 0.86); + radial-gradient(circle at 12% 12%, rgb(var(--surface-rgb) / 0.98), transparent 34%), + rgb(var(--surface-tint-rgb) / 0.86); } .semantic-trail-score { @@ -1073,28 +1081,28 @@ place-items: center; width: 46px; height: 46px; - border: 1px solid rgba(78, 169, 214, 0.28); + border: 1px solid rgb(var(--accent-mid-rgb) / 0.28); border-radius: 999px; background: - radial-gradient(circle at 34% 24%, rgba(255, 255, 255, 0.96), transparent 42%), - conic-gradient(from -80deg, rgba(53, 154, 211, 0.42), rgba(196, 237, 255, 0.16)), - rgba(255, 255, 255, 0.78); - color: #1d668f; + radial-gradient(circle at 34% 24%, rgb(var(--surface-rgb) / 0.96), transparent 42%), + conic-gradient(from -80deg, rgba(53, 154, 211, 0.42), rgb(var(--glow-pale-rgb) / 0.16)), + rgb(var(--surface-rgb) / 0.78); + color: rgb(var(--ocean-deep-rgb)); font-size: 10px; font-weight: 880; font-variant-numeric: tabular-nums; line-height: 1; box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.82), - 0 10px 24px rgba(78, 169, 214, 0.12); + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.82), + 0 10px 24px rgb(var(--accent-sky-rgb) / 0.12); } .semantic-trail-score svg { - color: rgba(29, 102, 143, 0.66); + color: rgb(var(--ocean-deep-rgb) / 0.66); } .semantic-trail-score strong { - color: #176b94; + color: rgb(var(--ocean-rgb)); font-size: 11px; font-weight: 920; } @@ -1128,7 +1136,7 @@ .semantic-trail-excerpt { display: -webkit-box; overflow: hidden; - color: #3f5269; + color: rgb(var(--soft-ink-rgb)); font-size: 11.5px; font-weight: 620; line-height: 1.48; @@ -1144,11 +1152,11 @@ } .semantic-trail-reasons span { - border: 1px solid rgba(97, 169, 210, 0.18); + border: 1px solid rgb(var(--accent-sky-rgb) / 0.18); border-radius: 999px; padding: 2px 6px; - background: rgba(230, 248, 255, 0.68); - color: #287196; + background: rgb(var(--surface-tint-cool-rgb) / 0.68); + color: rgb(var(--ocean-rgb)); font-size: 9px; font-weight: 820; } @@ -1158,8 +1166,8 @@ display: grid; gap: 4px; padding: 10px 11px; - border-color: rgba(139, 190, 215, 0.16); - background: rgba(246, 251, 255, 0.58); + border-color: rgb(var(--sky-muted-rgb) / 0.16); + background: rgb(var(--surface-tint-warm-rgb) / 0.58); color: var(--muted); font-size: 11px; font-weight: 700; @@ -1190,13 +1198,13 @@ place-items: stretch; margin-top: 10px; overflow: hidden; - border: 1px solid rgba(139, 190, 215, 0.24); + border: 1px solid rgb(var(--sky-muted-rgb) / 0.24); border-radius: 14px; background: - radial-gradient(circle at 50% 28%, rgba(255, 255, 255, 0.94), transparent 26%), - linear-gradient(145deg, rgba(248, 253, 255, 0.9), rgba(231, 249, 255, 0.76)); + radial-gradient(circle at 50% 28%, rgb(var(--surface-rgb) / 0.94), transparent 26%), + linear-gradient(145deg, rgb(var(--surface-tint-warm-rgb) / 0.9), rgb(var(--surface-tint-cool-rgb) / 0.76)); box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.82), + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.82), 0 16px 42px rgba(84, 160, 199, 0.12); } @@ -1240,8 +1248,8 @@ /* Frosted breath behind the crystal — a calm, slowly breathing icy glow. */ .answer-loading-haze { background: - radial-gradient(circle at 50% 44%, rgba(226, 248, 255, 0.72), transparent 56%), - radial-gradient(circle at 50% 60%, rgba(158, 222, 255, 0.3), transparent 62%); + radial-gradient(circle at 50% 44%, rgb(var(--surface-tint-cool-rgb) / 0.72), transparent 56%), + radial-gradient(circle at 50% 60%, rgb(var(--sky-mist-rgb) / 0.3), transparent 62%); filter: blur(17px); opacity: 0.85; animation: iceBreathe 7.5s ease-in-out infinite; @@ -1262,7 +1270,7 @@ .answer-card { position: relative; padding: 12px; - background: rgba(245, 250, 255, 0.78); + background: rgb(var(--surface-tint-warm-rgb) / 0.78); user-select: text; overflow: hidden; } @@ -1270,7 +1278,7 @@ .answer-markdown { max-height: min(36vh, 360px); overflow: auto; - color: #243143; + color: rgb(var(--ink-slate-rgb)); font-size: 13px; line-height: 1.5; } @@ -1302,7 +1310,7 @@ .answer-metrics-subtitle { margin: 5px 0 -5px; - color: rgba(78, 95, 120, 0.66); + color: rgb(var(--slate-steel-rgb) / 0.66); font-size: 11px; font-weight: 720; line-height: 1.3; @@ -1312,15 +1320,15 @@ height: 0; margin: 14px 0; border: 0; - border-top: 1px solid rgba(120, 190, 220, 0.34); + border-top: 1px solid rgb(var(--accent-soft-rgb) / 0.34); } .answer-inline-math { display: inline; border-radius: 5px; padding: 0 3px; - background: rgba(229, 248, 255, 0.58); - color: #29465f; + background: rgb(var(--surface-tint-cool-rgb) / 0.58); + color: rgb(var(--soft-ink-rgb)); font-family: 'Iowan Old Style', 'Palatino Linotype', Georgia, serif; font-size: 0.96em; font-weight: 680; @@ -1331,10 +1339,10 @@ align-items: center; height: 18px; margin: 0 1px; - border: 1px solid rgba(78, 143, 208, 0.22); + border: 1px solid rgb(var(--steel-rgb) / 0.22); border-radius: 999px; padding: 0 5px; - background: rgba(225, 243, 255, 0.68); + background: rgb(var(--surface-sky-rgb) / 0.68); color: var(--accent-strong); font-size: 0.86em; font-weight: 820; @@ -1345,9 +1353,9 @@ } .answer-citation-link:hover { - border-color: rgba(50, 137, 199, 0.42); - background: rgba(207, 236, 255, 0.92); - color: #12668f; + border-color: rgb(var(--accent-ocean-rgb) / 0.42); + background: rgb(var(--surface-sky-rgb) / 0.92); + color: rgb(var(--ocean-deep-rgb)); } .answer-heading { @@ -1381,10 +1389,10 @@ .answer-stop-button { height: 26px; - border: 1px solid rgba(78, 143, 208, 0.26); + border: 1px solid rgb(var(--steel-rgb) / 0.26); border-radius: 8px; padding: 0 9px; - background: rgba(235, 248, 255, 0.85); + background: rgb(var(--surface-tint-rgb) / 0.85); color: var(--accent-strong); font-size: 11px; font-weight: 780; @@ -1393,8 +1401,8 @@ } .answer-stop-button:hover { - border-color: rgba(50, 137, 199, 0.46); - background: rgba(213, 240, 255, 0.95); + border-color: rgb(var(--accent-ocean-rgb) / 0.46); + background: rgb(var(--surface-sky-rgb) / 0.95); } .answer-loading .answer-stop-button { @@ -1474,7 +1482,7 @@ .panel-status-text { grid-column: 1; min-width: 0; - color: rgba(82, 97, 115, 0.9); + color: rgb(var(--slate-steel-rgb) / 0.9); font-size: 11px; font-weight: 720; line-height: 1.35; @@ -1499,16 +1507,16 @@ grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 6px; - border: 1px solid rgba(105, 188, 224, 0.22); + border: 1px solid rgb(var(--accent-soft-rgb) / 0.22); border-radius: 999px; padding: 4px 7px 4px 9px; background: - radial-gradient(circle at 18% 16%, rgba(255, 255, 255, 0.95), transparent 42%), - linear-gradient(145deg, rgba(241, 252, 255, 0.84), rgba(255, 255, 255, 0.6)); - color: #2a7a9f; + radial-gradient(circle at 18% 16%, rgb(var(--surface-rgb) / 0.95), transparent 42%), + linear-gradient(145deg, rgb(var(--surface-tint-rgb) / 0.84), rgb(var(--surface-rgb) / 0.6)); + color: rgb(var(--accent-strong-rgb)); box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.72), - 0 8px 18px rgba(74, 145, 194, 0.08); + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.72), + 0 8px 18px rgb(var(--sky-deep-rgb) / 0.08); transition: border-color 0.4s ease, @@ -1517,15 +1525,15 @@ } .inline-model-selector:hover { - border: 2px solid rgba(142, 219, 255, 0.6); + border: 2px solid rgb(var(--accent-pale-rgb) / 0.6); box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.9), - 0 8px 24px rgba(105, 188, 224, 0.2), - 0 0 8px rgba(165, 230, 255, 0.3); /* Soft frost aura */ + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.9), + 0 8px 24px rgb(var(--accent-soft-rgb) / 0.2), + 0 0 8px rgb(var(--sky-mist-rgb) / 0.3); /* Soft frost aura */ } .inline-model-selector span { - color: #2a7a9f; + color: rgb(var(--accent-strong-rgb)); font-size: 10px; font-weight: 860; } @@ -1536,7 +1544,7 @@ border: 1px solid transparent; border-radius: 999px; padding: 0 20px 0 7px; - background: rgba(255, 255, 255, 0.52); + background: rgb(var(--surface-rgb) / 0.52); color: var(--ink); font-size: 11px; font-weight: 700; @@ -1549,13 +1557,13 @@ .inline-model-selector select:hover { cursor: pointer; - background-color: rgba(224, 247, 255, 0.75); - border-color: rgba(174, 227, 253, 0.4); - box-shadow: inset 0 1px 2px rgba(255, 255, 255, 0.6); + background-color: rgb(var(--surface-tint-cool-rgb) / 0.75); + border-color: rgb(var(--sky-pale-rgb) / 0.4); + box-shadow: inset 0 1px 2px rgb(var(--highlight-rgb) / 0.6); } .inline-model-selector select:focus { - box-shadow: 0 0 0 3px rgba(106, 193, 229, 0.18); + box-shadow: 0 0 0 3px rgb(var(--accent-light-rgb) / 0.18); } /* Icon-only sibling of the model selector. Sized to the selector's own pill so the @@ -1578,15 +1586,15 @@ justify-content: center; /* A base button rule contributes padding; without this the pill is not round. */ padding: 0; - border: 1px solid rgba(105, 188, 224, 0.22); + border: 1px solid rgb(var(--accent-soft-rgb) / 0.22); border-radius: 999px; background: - radial-gradient(circle at 18% 16%, rgba(255, 255, 255, 0.95), transparent 42%), - linear-gradient(145deg, rgba(241, 252, 255, 0.84), rgba(255, 255, 255, 0.6)); - color: #2a7a9f; + radial-gradient(circle at 18% 16%, rgb(var(--surface-rgb) / 0.95), transparent 42%), + linear-gradient(145deg, rgb(var(--surface-tint-rgb) / 0.84), rgb(var(--surface-rgb) / 0.6)); + color: rgb(var(--accent-strong-rgb)); box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.72), - 0 8px 18px rgba(74, 145, 194, 0.08); + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.72), + 0 8px 18px rgb(var(--sky-deep-rgb) / 0.08); cursor: pointer; transition: border-color 0.2s ease, @@ -1594,10 +1602,10 @@ } .inline-model-manage-button:hover:not(:disabled) { - border-color: rgba(142, 219, 255, 0.6); + border-color: rgb(var(--accent-pale-rgb) / 0.6); box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.9), - 0 8px 24px rgba(105, 188, 224, 0.2); + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.9), + 0 8px 24px rgb(var(--accent-soft-rgb) / 0.2); } .inline-model-manage-button:disabled { @@ -1615,28 +1623,28 @@ align-items: center; gap: 6px; height: 28px; - border: 1px solid rgba(105, 188, 224, 0.35); + border: 1px solid rgb(var(--accent-soft-rgb) / 0.35); border-radius: 999px; padding: 0 12px; - background: linear-gradient(145deg, rgba(226, 246, 255, 0.95), rgba(255, 255, 255, 0.7)); - color: #1f6c92; + background: linear-gradient(145deg, rgb(var(--surface-tint-cool-rgb) / 0.95), rgb(var(--surface-rgb) / 0.7)); + color: rgb(var(--ocean-rgb)); font-size: 11px; font-weight: 860; white-space: nowrap; cursor: pointer; box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.8), - 0 8px 18px rgba(74, 145, 194, 0.12); + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.8), + 0 8px 18px rgb(var(--sky-deep-rgb) / 0.12); transition: border-color 0.2s ease, box-shadow 0.2s ease; } .inline-model-setup-button:hover:not(:disabled) { - border-color: rgba(142, 219, 255, 0.7); + border-color: rgb(var(--accent-pale-rgb) / 0.7); box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.9), - 0 8px 24px rgba(105, 188, 224, 0.24); + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.9), + 0 8px 24px rgb(var(--accent-soft-rgb) / 0.24); } .inline-model-setup-button:disabled { @@ -1653,10 +1661,10 @@ width: 100%; height: 30px; margin-top: 4px; - border: 1px solid rgba(105, 188, 224, 0.35); + border: 1px solid rgb(var(--accent-soft-rgb) / 0.35); border-radius: 10px; - background: linear-gradient(145deg, rgba(226, 246, 255, 0.9), rgba(255, 255, 255, 0.68)); - color: #1f6c92; + background: linear-gradient(145deg, rgb(var(--surface-tint-cool-rgb) / 0.9), rgb(var(--surface-rgb) / 0.68)); + color: rgb(var(--ocean-rgb)); font-size: 11px; font-weight: 800; cursor: pointer; @@ -1664,7 +1672,7 @@ } .model-island-setup-button:hover:not(:disabled) { - border-color: rgba(142, 219, 255, 0.7); + border-color: rgb(var(--accent-pale-rgb) / 0.7); } .model-island-setup-button:disabled { diff --git a/src/renderer/src/assets/styles/knowledge-shelves.css b/src/renderer/src/assets/styles/knowledge-shelves.css index 2991814..3c78657 100644 --- a/src/renderer/src/assets/styles/knowledge-shelves.css +++ b/src/renderer/src/assets/styles/knowledge-shelves.css @@ -4,7 +4,7 @@ .knowledge-band { position: relative; z-index: 1; - border-top: 1px solid rgba(143, 165, 196, 0.18); + border-top: 1px solid rgb(var(--slate-rgb) / 0.18); padding: 22px 34px; } @@ -15,22 +15,22 @@ margin: 0 -34px; padding: 0; background: - linear-gradient(90deg, rgba(255, 255, 255, 0.48), rgba(239, 249, 255, 0.24)), - rgba(255, 255, 255, 0.3); + linear-gradient(90deg, rgb(var(--surface-rgb) / 0.48), rgb(var(--surface-tint-rgb) / 0.24)), + rgb(var(--surface-rgb) / 0.3); } .hub-row { margin: 0; padding: 16px 22px 18px 34px; - background: rgba(255, 255, 255, 0.38); + background: rgb(var(--surface-rgb) / 0.38); } .iceberg-band { margin: 0; padding: 16px 34px 18px 22px; background: - radial-gradient(circle at 12% 18%, rgba(186, 230, 253, 0.24), transparent 28%), - linear-gradient(135deg, rgba(255, 255, 255, 0.34), rgba(239, 249, 255, 0.2)); + radial-gradient(circle at 12% 18%, rgb(var(--sky-pale-rgb) / 0.24), transparent 28%), + linear-gradient(135deg, rgb(var(--surface-rgb) / 0.34), rgb(var(--surface-tint-rgb) / 0.2)); } .saved-shelves .hub-row, @@ -40,12 +40,12 @@ } .saved-shelves .iceberg-band { - border-left: 1px solid rgba(143, 165, 196, 0.18); + border-left: 1px solid rgb(var(--slate-rgb) / 0.18); } .knowledge-band { margin: 0 -34px; - background: rgba(255, 255, 255, 0.22); + background: rgb(var(--surface-rgb) / 0.22); } .section-title { @@ -75,7 +75,7 @@ overflow-y: auto; margin: 0; padding: 1px 5px 5px 1px; - scrollbar-color: rgba(82, 178, 219, 0.34) transparent; + scrollbar-color: rgb(var(--accent-bright-rgb) / 0.34) transparent; scrollbar-width: thin; padding-top: 2px; @@ -112,20 +112,20 @@ justify-items: start; column-gap: 8px; overflow: hidden; - border-color: rgba(105, 181, 216, 0.28); + border-color: rgb(var(--accent-soft-rgb) / 0.28); border-radius: 8px; padding: 8px 9px; isolation: isolate; background: - radial-gradient(circle at 92% 18%, rgba(185, 236, 255, 0.52), transparent 34%), - linear-gradient(145deg, rgba(255, 255, 255, 0.96), rgba(235, 249, 254, 0.84)); - color: #23364d; + radial-gradient(circle at 92% 18%, rgb(var(--sky-pale-rgb) / 0.52), transparent 34%), + linear-gradient(145deg, rgb(var(--surface-rgb) / 0.96), rgb(var(--surface-tint-rgb) / 0.84)); + color: rgb(var(--ink-slate-rgb)); cursor: pointer; text-align: left; box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.9), - inset 0 -10px 22px rgba(105, 181, 216, 0.07), - 0 10px 22px rgba(73, 130, 171, 0.1); + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.9), + inset 0 -10px 22px rgb(var(--accent-sky-rgb) / 0.07), + 0 10px 22px rgb(var(--shadow-soft-rgb) / 0.1); } .saved-iceberg-open::before { @@ -134,28 +134,28 @@ z-index: -1; content: ''; background: - linear-gradient(118deg, transparent 0 38%, rgba(126, 205, 233, 0.14) 39% 40%, transparent 41%), - radial-gradient(circle at 40% 0%, rgba(255, 255, 255, 0.86), transparent 30%); + linear-gradient(118deg, transparent 0 38%, rgb(var(--sky-soft-rgb) / 0.14) 39% 40%, transparent 41%), + radial-gradient(circle at 40% 0%, rgb(var(--surface-rgb) / 0.86), transparent 30%); opacity: 0.72; } .saved-iceberg-card.drop-target .saved-iceberg-open, .saved-iceberg-card:hover .saved-iceberg-open, .saved-iceberg-open:hover { - border-color: rgba(75, 163, 206, 0.42); + border-color: rgb(var(--accent-mid-rgb) / 0.42); background: - radial-gradient(circle at 92% 18%, rgba(207, 244, 255, 0.68), transparent 34%), - linear-gradient(145deg, rgba(255, 255, 255, 1), rgba(229, 248, 254, 0.9)); + radial-gradient(circle at 92% 18%, rgb(var(--surface-sky-rgb) / 0.68), transparent 34%), + linear-gradient(145deg, rgb(var(--surface-rgb) / 1), rgb(var(--surface-tint-cool-rgb) / 0.9)); box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.96), - inset 0 -10px 22px rgba(105, 181, 216, 0.1), - 0 12px 24px rgba(73, 130, 171, 0.16); + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.96), + inset 0 -10px 22px rgb(var(--accent-sky-rgb) / 0.1), + 0 12px 24px rgb(var(--shadow-soft-rgb) / 0.16); } .saved-iceberg-open span, .saved-iceberg-open small { max-width: 100%; - color: #5d7f98; + color: rgb(var(--slate-blue-rgb)); font-size: 9px; opacity: 0.75; font-weight: 900; @@ -178,11 +178,11 @@ grid-column: 1; max-width: 100%; overflow: hidden; - color: #20344c; + color: rgb(var(--ink-slate-rgb)); font-size: 12px; font-weight: 900; line-height: 1.08; - text-shadow: 0 1px 0 rgba(255, 255, 255, 0.8); + text-shadow: 0 1px 0 rgb(var(--highlight-rgb) / 0.8); -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; @@ -198,18 +198,18 @@ place-items: center; width: 21px; height: 21px; - border-color: rgba(105, 181, 216, 0.24); + border-color: rgb(var(--accent-soft-rgb) / 0.24); border-radius: 999px; padding: 0; - background: rgba(255, 255, 255, 0.72); - color: #6f8aa3; + background: rgb(var(--surface-rgb) / 0.72); + color: rgb(var(--slate-cool-rgb)); box-shadow: none; backdrop-filter: blur(10px); } .saved-iceberg-delete:hover { - border-color: rgba(184, 91, 89, 0.24); - background: rgba(255, 239, 239, 0.86); + border-color: rgb(var(--danger-rgb) / 0.24); + background: rgb(var(--surface-blush-rgb) / 0.86); color: var(--danger); } @@ -227,16 +227,16 @@ place-items: center; width: 24px; height: 24px; - border: 1px solid rgba(105, 181, 216, 0.28); + border: 1px solid rgb(var(--accent-soft-rgb) / 0.28); border-radius: 8px; background: - radial-gradient(circle at 35% 20%, rgba(255, 255, 255, 0.96), transparent 44%), - linear-gradient(145deg, rgba(229, 248, 255, 0.9), rgba(151, 216, 238, 0.42)); - color: #2a87ad; + radial-gradient(circle at 35% 20%, rgb(var(--surface-rgb) / 0.96), transparent 44%), + linear-gradient(145deg, rgb(var(--surface-tint-cool-rgb) / 0.9), rgb(var(--sky-mist-rgb) / 0.42)); + color: rgb(var(--accent-strong-rgb)); pointer-events: none; box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.82), - 0 6px 14px rgba(73, 130, 171, 0.12); + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.82), + 0 6px 14px rgb(var(--shadow-soft-rgb) / 0.12); backdrop-filter: blur(12px); } @@ -245,13 +245,13 @@ place-items: center; width: 36px; height: 36px; - border: 1px solid rgba(79, 174, 202, 0.22); + border: 1px solid rgb(var(--accent-muted-rgb) / 0.22); border-radius: 10px; color: var(--accent-strong); background: - radial-gradient(circle at 30% 18%, rgba(255, 255, 255, 0.96), transparent 42%), - linear-gradient(145deg, rgba(238, 252, 255, 0.9), rgba(255, 255, 255, 0.74)); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.84); + radial-gradient(circle at 30% 18%, rgb(var(--surface-rgb) / 0.96), transparent 42%), + linear-gradient(145deg, rgb(var(--surface-tint-rgb) / 0.9), rgb(var(--surface-rgb) / 0.74)); + box-shadow: inset 0 1px 0 rgb(var(--highlight-rgb) / 0.84); } .section-title.compact .section-symbol { @@ -320,39 +320,39 @@ flex: 1 1 280px; min-width: 0; padding: 8px 14px; - border: 1px solid rgba(75, 163, 206, 0.32); + border: 1px solid rgb(var(--accent-mid-rgb) / 0.32); border-radius: 11px; - background: rgba(255, 255, 255, 0.96); - color: var(--text-strong, #12304a); + background: rgb(var(--surface-rgb) / 0.96); + color: var(--text-strong, rgb(var(--ink-deep-rgb))); } .library-search input[type='search']::placeholder { - color: rgba(58, 105, 138, 0.6); + color: rgb(var(--text-ocean-rgb) / 0.6); } .library-search select { flex: 0 0 auto; max-width: 170px; padding: 8px 10px; - border: 1px solid rgba(75, 163, 206, 0.3); + border: 1px solid rgb(var(--accent-mid-rgb) / 0.3); border-radius: 11px; - background: rgba(255, 255, 255, 0.94); + background: rgb(var(--surface-rgb) / 0.94); } .library-search button { flex: 0 0 auto; padding: 8px 16px; - border: 1px solid rgba(75, 163, 206, 0.44); + border: 1px solid rgb(var(--accent-mid-rgb) / 0.44); border-radius: 11px; - background: linear-gradient(145deg, rgba(238, 250, 255, 0.98), rgba(197, 232, 250, 0.94)); + background: linear-gradient(145deg, rgb(var(--surface-tint-rgb) / 0.98), rgb(var(--sky-pale-rgb) / 0.94)); font-weight: 660; - color: #0e4767; + color: rgb(var(--ink-navy-rgb)); cursor: pointer; } .library-search button:hover:not(:disabled) { - border-color: rgba(75, 163, 206, 0.66); - background: linear-gradient(145deg, rgba(255, 255, 255, 1), rgba(184, 227, 249, 0.98)); + border-color: rgb(var(--accent-mid-rgb) / 0.66); + background: linear-gradient(145deg, rgb(var(--surface-rgb) / 1), rgb(var(--sky-pale-rgb) / 0.98)); } .library-search button:disabled, @@ -375,16 +375,16 @@ margin: 0 0 8px; font-size: 12px; font-weight: 680; - color: rgba(30, 79, 112, 0.9); + color: rgb(var(--ink-navy-rgb) / 0.9); } .library-search-mode { padding: 2px 8px; border-radius: 999px; - background: rgba(246, 224, 174, 0.44); + background: rgb(var(--surface-gold-rgb) / 0.44); font-size: 10.5px; font-weight: 660; - color: #6d4c15; + color: rgb(var(--glint-deep-rgb)); } .library-search-results ul { @@ -405,17 +405,17 @@ width: 100%; height: auto; padding: 10px 12px; - border: 1px solid rgba(75, 163, 206, 0.24); + border: 1px solid rgb(var(--accent-mid-rgb) / 0.24); border-radius: 12px; - background: linear-gradient(145deg, rgba(255, 255, 255, 0.96), rgba(236, 247, 253, 0.8)); + background: linear-gradient(145deg, rgb(var(--surface-rgb) / 0.96), rgb(var(--surface-tint-rgb) / 0.8)); text-align: left; font-weight: 500; cursor: pointer; } .library-search-results button:hover { - border-color: rgba(75, 163, 206, 0.5); - background: linear-gradient(145deg, rgba(255, 255, 255, 1), rgba(223, 243, 253, 0.94)); + border-color: rgb(var(--accent-mid-rgb) / 0.5); + background: linear-gradient(145deg, rgb(var(--surface-rgb) / 1), rgb(var(--surface-sky-rgb) / 0.94)); } .library-search-hit-head { @@ -427,26 +427,26 @@ .library-search-hit-head strong { font-size: 13px; - color: #12304a; + color: rgb(var(--ink-deep-rgb)); } .library-search-score { flex: 0 0 auto; font-size: 11px; font-weight: 700; - color: rgba(30, 110, 158, 0.92); + color: rgb(var(--ocean-rgb) / 0.92); } .library-search-hit-meta { font-size: 11px; font-weight: 620; - color: rgba(58, 105, 138, 0.78); + color: rgb(var(--text-ocean-rgb) / 0.78); } .library-search-excerpt { font-size: 11.5px; line-height: 1.45; - color: rgba(40, 74, 100, 0.82); + color: rgb(var(--soft-ink-rgb) / 0.82); } /* Capture-by-link row. Sits directly under the section title so adding a source is @@ -460,10 +460,10 @@ width: 100%; margin-bottom: 12px; padding: 10px 12px; - border: 1px solid rgba(75, 163, 206, 0.26); + border: 1px solid rgb(var(--accent-mid-rgb) / 0.26); border-radius: 14px; - background: linear-gradient(145deg, rgba(255, 255, 255, 0.94), rgba(233, 246, 253, 0.72)); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9); + background: linear-gradient(145deg, rgb(var(--surface-rgb) / 0.94), rgb(var(--surface-tint-cool-rgb) / 0.72)); + box-shadow: inset 0 1px 0 rgb(var(--highlight-rgb) / 0.9); } /* Input, select and button each compute a different intrinsic height from their @@ -481,25 +481,25 @@ flex: 1 1 260px; min-width: 0; padding: 8px 12px; - border: 1px solid rgba(75, 163, 206, 0.3); + border: 1px solid rgb(var(--accent-mid-rgb) / 0.3); border-radius: 10px; - background: rgba(255, 255, 255, 0.92); + background: rgb(var(--surface-rgb) / 0.92); font: inherit; font-size: 13px; - color: var(--text-strong, #12304a); + color: var(--text-strong, rgb(var(--ink-deep-rgb))); } .hub-link-capture input[type='text']::placeholder { - color: rgba(58, 105, 138, 0.62); + color: rgb(var(--text-ocean-rgb) / 0.62); } .hub-link-capture select { flex: 0 0 auto; max-width: 180px; padding: 8px 10px; - border: 1px solid rgba(75, 163, 206, 0.3); + border: 1px solid rgb(var(--accent-mid-rgb) / 0.3); border-radius: 10px; - background: rgba(255, 255, 255, 0.92); + background: rgb(var(--surface-rgb) / 0.92); font: inherit; font-size: 13px; } @@ -507,19 +507,19 @@ .hub-link-capture button { flex: 0 0 auto; padding: 8px 14px; - border: 1px solid rgba(75, 163, 206, 0.4); + border: 1px solid rgb(var(--accent-mid-rgb) / 0.4); border-radius: 10px; - background: linear-gradient(145deg, rgba(238, 250, 255, 0.98), rgba(203, 235, 250, 0.92)); + background: linear-gradient(145deg, rgb(var(--surface-tint-rgb) / 0.98), rgb(var(--sky-pale-rgb) / 0.92)); font: inherit; font-size: 13px; font-weight: 650; - color: #10496b; + color: rgb(var(--ink-navy-rgb)); cursor: pointer; } .hub-link-capture button:hover:not(:disabled) { - border-color: rgba(75, 163, 206, 0.62); - background: linear-gradient(145deg, rgba(255, 255, 255, 1), rgba(190, 230, 249, 0.96)); + border-color: rgb(var(--accent-mid-rgb) / 0.62); + background: linear-gradient(145deg, rgb(var(--surface-rgb) / 1), rgb(var(--sky-pale-rgb) / 0.96)); } .hub-link-capture button:disabled, @@ -533,7 +533,7 @@ flex: 1 0 100%; font-size: 11px; font-weight: 600; - color: rgba(58, 105, 138, 0.72); + color: rgb(var(--text-ocean-rgb) / 0.72); } @media (prefers-reduced-motion: reduce) { @@ -650,17 +650,17 @@ align-items: center; gap: 5px; padding: 0 10px; - border-color: rgba(86, 139, 218, 0.3); + border-color: rgb(var(--steel-rgb) / 0.3); background: - radial-gradient(circle at 30% 18%, rgba(255, 255, 255, 0.9), transparent 42%), - linear-gradient(135deg, rgba(233, 244, 255, 0.95), rgba(255, 255, 255, 0.82)); + radial-gradient(circle at 30% 18%, rgb(var(--surface-rgb) / 0.9), transparent 42%), + linear-gradient(135deg, rgb(var(--surface-tint-cool-rgb) / 0.95), rgb(var(--surface-rgb) / 0.82)); color: var(--accent-strong); font-weight: 800; } .knowledge-band .collection-ask:hover:not(:disabled) { - border-color: rgba(86, 139, 218, 0.5); - box-shadow: 0 8px 18px rgba(74, 145, 194, 0.14); + border-color: rgb(var(--steel-rgb) / 0.5); + box-shadow: 0 8px 18px rgb(var(--sky-deep-rgb) / 0.14); transform: translateY(-1px); } @@ -725,6 +725,6 @@ overflow-y: auto; margin: 0; padding: 1px 5px 5px 1px; - scrollbar-color: rgba(82, 178, 219, 0.34) transparent; + scrollbar-color: rgb(var(--accent-bright-rgb) / 0.34) transparent; scrollbar-width: thin; } diff --git a/src/renderer/src/assets/styles/mobile-shell.css b/src/renderer/src/assets/styles/mobile-shell.css index eae0cae..ebcbac6 100644 --- a/src/renderer/src/assets/styles/mobile-shell.css +++ b/src/renderer/src/assets/styles/mobile-shell.css @@ -30,8 +30,8 @@ .aether-mobile input:focus, .aether-mobile select:focus, .aether-mobile textarea:focus { - border-color: rgba(78, 134, 215, 0.62); - box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9); + border-color: rgb(var(--steel-rgb) / 0.62); + box-shadow: inset 0 1px 0 rgb(var(--highlight-rgb) / 0.9); } /* ------------------------------------------------------------ performance @@ -126,8 +126,8 @@ border-left: 1px solid var(--line-strong); padding: 10px 16px 16px; background: - radial-gradient(circle at 24% 0%, rgba(224, 249, 255, 0.9), transparent 42%), - linear-gradient(175deg, #fdfeff 0%, #eef8ff 100%); + radial-gradient(circle at 24% 0%, rgb(var(--surface-tint-cool-rgb) / 0.9), transparent 42%), + linear-gradient(175deg, rgb(var(--surface-rgb)) 0%, rgb(var(--surface-tint-rgb)) 100%); } /* The native tab WebView placeholder fills the content area; its rect is @@ -165,8 +165,8 @@ overflow: hidden; border-top: 1px solid var(--line); background: - linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(233, 247, 255, 0.96)); - box-shadow: 0 -10px 30px rgba(62, 132, 163, 0.12); + linear-gradient(180deg, rgb(var(--surface-rgb) / 0.94), rgb(var(--surface-tint-cool-rgb) / 0.96)); + box-shadow: 0 -10px 30px rgb(var(--shadow-soft-rgb) / 0.12); transition: grid-template-rows 240ms cubic-bezier(0.4, 0, 0.2, 1), visibility 0s 0s; @@ -224,7 +224,7 @@ padding: 0 9px; color: var(--soft-ink); font-size: 12px; - background: rgba(255, 255, 255, 0.6); + background: rgb(var(--surface-rgb) / 0.6); box-shadow: inset 0 -2px 0 var(--tab-tint, transparent); } @@ -232,7 +232,7 @@ border-color: var(--line-strong); color: var(--ink); font-weight: 600; - background: rgba(255, 255, 255, 0.96); + background: rgb(var(--surface-rgb) / 0.96); } .mobile-tab-chip-icon { @@ -267,7 +267,7 @@ height: 18px; border-radius: 50%; color: var(--muted); - background: rgba(23, 36, 58, 0.06); + background: rgb(var(--ink-rgb) / 0.06); } .mobile-tab-chip-close svg { @@ -291,7 +291,7 @@ .mobile-tab-grid-button { border-color: var(--line); - background: rgba(255, 255, 255, 0.6); + background: rgb(var(--surface-rgb) / 0.6); } .mobile-tab-new svg, @@ -329,7 +329,7 @@ .mobile-nav-button.active { color: var(--accent-strong); - background: rgba(78, 184, 223, 0.14); + background: rgb(var(--accent-rgb) / 0.14); } .mobile-nav-button.aion { @@ -351,8 +351,8 @@ border: 1px solid var(--line-strong); border-radius: 999px; padding: 0 14px; - background: rgba(255, 255, 255, 0.85); - box-shadow: inset 0 1px 4px rgba(51, 103, 137, 0.08); + background: rgb(var(--surface-rgb) / 0.85); + box-shadow: inset 0 1px 4px rgb(var(--shadow-rgb) / 0.08); } .mobile-address-pill svg { @@ -383,8 +383,8 @@ flex-direction: column; padding-top: var(--aether-inset-top, 0px); background: - radial-gradient(circle at 20% 6%, rgba(204, 247, 255, 0.6), transparent 34%), - linear-gradient(160deg, #f3fbff 0%, #e7f4fd 60%, #f6fbff 100%); + radial-gradient(circle at 20% 6%, rgb(var(--glow-pale-rgb) / 0.6), transparent 34%), + linear-gradient(160deg, rgb(var(--surface-tint-rgb)) 0%, rgb(var(--surface-tint-cool-rgb)) 60%, rgb(var(--surface-tint-warm-rgb)) 100%); } .mobile-overlay-header { @@ -411,7 +411,7 @@ border: 1px solid var(--line); border-radius: 12px; color: var(--soft-ink); - background: rgba(255, 255, 255, 0.7); + background: rgb(var(--surface-rgb) / 0.7); } .mobile-overlay-header button svg { @@ -437,14 +437,14 @@ overflow: hidden; border: 1.5px solid var(--line); border-radius: 16px; - background: rgba(255, 255, 255, 0.88); + background: rgb(var(--surface-rgb) / 0.88); box-shadow: var(--soft-shadow); } .mobile-tab-card.active { border-color: var(--accent); box-shadow: - 0 0 0 3px rgba(78, 184, 223, 0.22), + 0 0 0 3px rgb(var(--accent-rgb) / 0.22), var(--soft-shadow); } @@ -456,7 +456,7 @@ height: 34px; border-bottom: 1px solid var(--line); padding: 0 6px 0 10px; - background: linear-gradient(180deg, rgba(255, 255, 255, 0.95), rgba(240, 250, 255, 0.9)); + background: linear-gradient(180deg, rgb(var(--surface-rgb) / 0.95), rgb(var(--surface-tint-rgb) / 0.9)); box-shadow: inset 0 2px 0 var(--tab-tint, transparent); } @@ -507,7 +507,7 @@ position: relative; overflow: hidden; aspect-ratio: 3 / 4; - background: rgba(246, 249, 253, 0.9); + background: rgb(var(--surface-tint-warm-rgb) / 0.9); } .mobile-tab-card-preview img { @@ -554,7 +554,7 @@ color: var(--accent-strong); font-size: 13px; font-weight: 600; - background: rgba(255, 255, 255, 0.5); + background: rgb(var(--surface-rgb) / 0.5); } .mobile-tab-card-new svg { @@ -571,7 +571,7 @@ display: flex; flex-direction: column; justify-content: flex-end; - background: rgba(23, 36, 58, 0.4); + background: rgb(var(--ink-rgb) / 0.4); animation: mobile-sheet-fade 200ms ease; } @@ -613,9 +613,9 @@ border-radius: 22px 22px 0 0; padding: 8px 16px calc(18px + var(--aether-inset-bottom, 0px)); background: - radial-gradient(circle at 24% 0%, rgba(224, 249, 255, 0.9), transparent 42%), - linear-gradient(175deg, #fdfeff 0%, #eef8ff 100%); - box-shadow: 0 -20px 60px rgba(51, 103, 137, 0.28); + radial-gradient(circle at 24% 0%, rgb(var(--surface-tint-cool-rgb) / 0.9), transparent 42%), + linear-gradient(175deg, rgb(var(--surface-rgb)) 0%, rgb(var(--surface-tint-rgb)) 100%); + box-shadow: 0 -20px 60px rgb(var(--shadow-rgb) / 0.28); } /* Solid bleed hanging below the sheet: while a drag translates the sheet @@ -627,7 +627,7 @@ right: -1px; left: -1px; height: 45vh; - background: #eef8ff; + background: rgb(var(--surface-tint-rgb)); content: ''; } @@ -649,7 +649,7 @@ height: 4px; margin: 0; border-radius: 999px; - background: rgba(113, 129, 154, 0.4); + background: rgb(var(--muted-rgb) / 0.4); } /* actions sheet */ @@ -682,7 +682,7 @@ padding: 0 10px; color: var(--ink); font-size: 14px; - background: rgba(255, 255, 255, 0.9); + background: rgb(var(--surface-rgb) / 0.9); } .mobile-capture-button { @@ -694,11 +694,11 @@ border: none; border-radius: 12px; padding: 0 16px; - color: #fff; + color: rgb(var(--highlight-rgb)); font-size: 14px; font-weight: 600; background: linear-gradient(135deg, var(--accent), var(--accent-strong)); - box-shadow: 0 8px 22px rgba(36, 127, 167, 0.32); + box-shadow: 0 8px 22px rgb(var(--accent-strong-rgb) / 0.32); } .mobile-capture-button svg { @@ -722,7 +722,7 @@ gap: 12px; height: 48px; border: none; - border-bottom: 1px solid rgba(123, 158, 190, 0.16); + border-bottom: 1px solid rgb(var(--line-rgb) / 0.16); padding: 0 4px; color: var(--ink); font-size: 15px; @@ -781,7 +781,7 @@ border: none; border-radius: 10px; color: var(--soft-ink); - background: rgba(23, 36, 58, 0.05); + background: rgb(var(--ink-rgb) / 0.05); } .mobile-aion-header button svg { @@ -809,7 +809,7 @@ border: none; border-radius: 12px; padding: 0 18px; - color: #fff; + color: rgb(var(--highlight-rgb)); font-size: 14px; font-weight: 600; background: linear-gradient(135deg, var(--accent), var(--accent-strong)); @@ -834,14 +834,14 @@ font-size: 12.5px; white-space: nowrap; text-overflow: ellipsis; - background: rgba(255, 255, 255, 0.7); + background: rgb(var(--surface-rgb) / 0.7); } .mobile-context-chip.active { border-color: var(--accent); color: var(--accent-strong); font-weight: 600; - background: rgba(78, 184, 223, 0.12); + background: rgb(var(--accent-rgb) / 0.12); } .mobile-aion-context select { @@ -853,7 +853,7 @@ padding: 0 10px; color: var(--soft-ink); font-size: 12.5px; - background: rgba(255, 255, 255, 0.7); + background: rgb(var(--surface-rgb) / 0.7); } .mobile-quick-actions { @@ -872,12 +872,12 @@ .mobile-quick-actions button { flex: none; height: 32px; - border: 1px solid rgba(143, 114, 207, 0.34); + border: 1px solid rgb(var(--prism-rgb) / 0.34); border-radius: 999px; padding: 0 12px; color: var(--prism); font-size: 12.5px; - background: rgba(143, 114, 207, 0.08); + background: rgb(var(--prism-rgb) / 0.08); } .mobile-aion-form { @@ -896,7 +896,7 @@ color: var(--ink); font-family: inherit; font-size: 15px; - background: rgba(255, 255, 255, 0.92); + background: rgb(var(--surface-rgb) / 0.92); resize: none; outline: none; } @@ -907,7 +907,7 @@ border: none; border-radius: 12px; padding: 0 18px; - color: #fff; + color: rgb(var(--highlight-rgb)); font-size: 14px; font-weight: 600; background: linear-gradient(135deg, var(--accent), var(--accent-strong)); @@ -1000,7 +1000,7 @@ border-radius: 12px; padding: 10px 12px; text-align: left; - background: rgba(255, 255, 255, 0.7); + background: rgb(var(--surface-rgb) / 0.7); } .mobile-aion-citations button > svg { @@ -1022,7 +1022,7 @@ color: var(--accent-strong); font-size: 11px; font-weight: 700; - background: rgba(78, 184, 223, 0.16); + background: rgb(var(--accent-rgb) / 0.16); } .mobile-citation-body { @@ -1111,7 +1111,7 @@ margin: 0; padding: 30px var(--mobile-gutter) 28px; text-align: center; - background: linear-gradient(180deg, rgba(255, 255, 255, 0.55), rgba(255, 255, 255, 0)); + background: linear-gradient(180deg, rgb(var(--surface-rgb) / 0.55), rgb(var(--surface-rgb) / 0)); } /* No decorative layers on the phone hero: the blurred ellipse, dust field, @@ -1141,7 +1141,7 @@ .aether-mobile .hero-orb img { width: 124px; height: 124px; - filter: drop-shadow(0 14px 22px rgba(58, 115, 146, 0.22)); + filter: drop-shadow(0 14px 22px rgb(var(--ocean-rgb) / 0.22)); } /* Desktop fills the wordmark with a gradient that fades the tail letters into @@ -1149,12 +1149,12 @@ .aether-mobile .dashboard-hero h1 { max-width: none; margin-top: 12px; - color: #14233c; + color: rgb(var(--ink-rgb)); -webkit-text-fill-color: currentcolor; background: none; font-size: 40px; letter-spacing: 0.16em; - text-shadow: 0 10px 24px rgba(67, 127, 154, 0.14); + text-shadow: 0 10px 24px rgb(var(--shadow-soft-rgb) / 0.14); } .aether-mobile .dashboard-hero p { @@ -1183,7 +1183,7 @@ } .aether-mobile .saved-shelves .iceberg-band { - border-top: 1px solid rgba(143, 165, 196, 0.18); + border-top: 1px solid rgb(var(--slate-rgb) / 0.18); border-left: none; } @@ -1246,7 +1246,7 @@ .aether-mobile .saved-shelves .iceberg-band { border-top: 0; - border-left: 1px solid rgba(143, 165, 196, 0.18); + border-left: 1px solid rgb(var(--slate-rgb) / 0.18); } .aether-mobile .section-title { @@ -1457,7 +1457,7 @@ .aether-mobile .crystallizer-dock { height: auto; border-left: 0; - border-top: 1px solid rgba(125, 211, 252, 0.26); + border-top: 1px solid rgb(var(--sky-rgb) / 0.26); padding: 14px 14px 18px; } diff --git a/src/renderer/src/assets/styles/setup-settings.css b/src/renderer/src/assets/styles/setup-settings.css index e31ba03..569725d 100644 --- a/src/renderer/src/assets/styles/setup-settings.css +++ b/src/renderer/src/assets/styles/setup-settings.css @@ -6,9 +6,9 @@ place-items: center; padding: 28px; background: - radial-gradient(circle at 28% 18%, rgba(210, 247, 255, 0.58), transparent 28%), - radial-gradient(circle at 72% 82%, rgba(166, 219, 255, 0.34), transparent 30%), - rgba(242, 249, 255, 0.72); + radial-gradient(circle at 28% 18%, rgb(var(--surface-sky-rgb) / 0.58), transparent 28%), + radial-gradient(circle at 72% 82%, rgb(var(--sky-mist-rgb) / 0.34), transparent 30%), + rgb(var(--surface-tint-rgb) / 0.72); backdrop-filter: blur(26px) saturate(1.18); animation: settings-overlay-in 260ms ease both; } @@ -27,11 +27,11 @@ background: linear-gradient( 135deg, - rgba(246, 252, 255, 0.86), - rgba(232, 247, 255, 0.72) 42%, - rgba(248, 244, 255, 0.78) + rgb(var(--surface-tint-warm-rgb) / 0.86), + rgb(var(--surface-tint-cool-rgb) / 0.72) 42%, + rgb(var(--surface-violet-rgb) / 0.78) ), - rgba(242, 249, 255, 0.76); + rgb(var(--surface-tint-rgb) / 0.76); backdrop-filter: blur(30px) saturate(1.2); animation: settings-overlay-in 260ms ease both; } @@ -47,20 +47,20 @@ max-height: calc(100vh - 150px); overflow-x: hidden; overflow-y: auto; - border: 1px solid rgba(105, 164, 205, 0.32); + border: 1px solid rgb(var(--accent-sky-rgb) / 0.32); border-radius: 26px; padding: 18px; background: linear-gradient( 120deg, - rgba(255, 255, 255, 0.96), - rgba(238, 250, 255, 0.9) 52%, - rgba(250, 247, 255, 0.92) + rgb(var(--surface-rgb) / 0.96), + rgb(var(--surface-tint-rgb) / 0.9) 52%, + rgb(var(--surface-tint-warm-rgb) / 0.92) ), - rgba(255, 255, 255, 0.9); + rgb(var(--surface-rgb) / 0.9); box-shadow: - 0 34px 90px rgba(45, 88, 130, 0.24), - inset 0 1px 0 rgba(255, 255, 255, 0.92); + 0 34px 90px rgb(var(--ocean-deep-rgb) / 0.24), + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.92); isolation: isolate; animation: settings-modal-in 520ms cubic-bezier(0.16, 1, 0.3, 1) both; } @@ -87,16 +87,16 @@ z-index: -1; content: ''; background: - linear-gradient(115deg, transparent 0 18%, rgba(113, 217, 234, 0.2) 28%, transparent 40% 100%), - linear-gradient(72deg, transparent 0 48%, rgba(148, 121, 214, 0.14) 58%, transparent 70% 100%); - mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.95), rgba(0, 0, 0, 0.48)); + linear-gradient(115deg, transparent 0 18%, rgb(var(--aqua-rgb) / 0.2) 28%, transparent 40% 100%), + linear-gradient(72deg, transparent 0 48%, rgb(var(--prism-rgb) / 0.14) 58%, transparent 70% 100%); + mask-image: linear-gradient(180deg, rgb(var(--mask-rgb) / 0.95), rgb(var(--mask-rgb) / 0.48)); } .model-setup-glass { position: absolute; inset: 10px; z-index: -1; - border: 1px solid rgba(255, 255, 255, 0.68); + border: 1px solid rgb(var(--edge-rgb) / 0.68); border-radius: 20px; pointer-events: none; } @@ -121,11 +121,11 @@ width: fit-content; align-items: center; gap: 8px; - border: 1px solid rgba(77, 168, 202, 0.24); + border: 1px solid rgb(var(--accent-muted-rgb) / 0.24); border-radius: 999px; padding: 6px 10px; - background: rgba(255, 255, 255, 0.62); - color: #287c9f; + background: rgb(var(--surface-rgb) / 0.62); + color: rgb(var(--accent-strong-rgb)); font-size: 11px; font-weight: 900; letter-spacing: 0.12em; @@ -139,7 +139,7 @@ .model-setup-copy h1 { margin: 0; - color: #162f4e; + color: rgb(var(--ink-slate-rgb)); font-family: var(--font-display); font-size: clamp(40px, 6.2vw, 68px); font-weight: 760; @@ -150,7 +150,7 @@ .model-setup-copy p { max-width: 620px; margin: 0; - color: #4f6884; + color: rgb(var(--slate-ocean-rgb)); font-size: 14px; font-weight: 640; line-height: 1.42; @@ -174,9 +174,9 @@ content: ''; border-radius: 999px; background: - radial-gradient(circle at 48% 38%, rgba(255, 255, 255, 0.72), transparent 30%), - radial-gradient(circle at 50% 50%, rgba(186, 230, 253, 0.38), transparent 50%), - radial-gradient(circle at 50% 50%, rgba(45, 212, 191, 0.14), transparent 68%); + radial-gradient(circle at 48% 38%, rgb(var(--surface-rgb) / 0.72), transparent 30%), + radial-gradient(circle at 50% 50%, rgb(var(--sky-pale-rgb) / 0.38), transparent 50%), + radial-gradient(circle at 50% 50%, rgb(var(--aurora-vivid-rgb) / 0.14), transparent 68%); filter: blur(6px); opacity: 0.86; animation: heroOrbHalo 7.5s ease-in-out infinite; @@ -192,8 +192,8 @@ background: linear-gradient( 118deg, transparent 12%, - rgba(255, 255, 255, 0.72) 28%, - rgba(207, 245, 255, 0.32) 39%, + rgb(var(--surface-rgb) / 0.72) 28%, + rgb(var(--surface-sky-rgb) / 0.32) 39%, transparent 54% ); filter: blur(0.4px); @@ -210,9 +210,9 @@ width: min(150px, 74%); max-height: 150px; object-fit: contain; - filter: drop-shadow(0 0 2px rgba(255, 255, 255, 0.96)) - drop-shadow(0 0 8px rgba(255, 255, 255, 0.78)) drop-shadow(0 0 20px rgba(149, 224, 255, 0.58)) - drop-shadow(0 18px 28px rgba(48, 119, 170, 0.22)); + filter: drop-shadow(0 0 2px rgb(var(--highlight-rgb) / 0.96)) + drop-shadow(0 0 8px rgb(var(--highlight-rgb) / 0.78)) drop-shadow(0 0 20px rgb(var(--accent-pale-rgb) / 0.58)) + drop-shadow(0 18px 28px rgb(var(--azure-rgb) / 0.22)); pointer-events: none; user-select: none; -webkit-user-drag: none; @@ -253,15 +253,15 @@ gap: 10px; align-items: center; min-height: 98px; - border: 1px solid rgba(111, 153, 199, 0.22); + border: 1px solid rgb(var(--steel-soft-rgb) / 0.22); border-radius: 18px; padding: 10px 12px; background: - linear-gradient(130deg, rgba(255, 255, 255, 0.82), rgba(237, 249, 255, 0.6)), - rgba(255, 255, 255, 0.7); + linear-gradient(130deg, rgb(var(--surface-rgb) / 0.82), rgb(var(--surface-tint-rgb) / 0.6)), + rgb(var(--surface-rgb) / 0.7); box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.76), - 0 12px 28px rgba(68, 115, 169, 0.1); + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.76), + 0 12px 28px rgb(var(--steel-deep-rgb) / 0.1); cursor: pointer; transition: transform 180ms ease, @@ -271,15 +271,15 @@ } .model-choice:hover { - border-color: rgba(54, 140, 183, 0.34); + border-color: rgb(var(--accent-ocean-rgb) / 0.34); transform: translateY(-1px); } .model-choice.installed { border-color: rgba(72, 153, 119, 0.24); background: - linear-gradient(130deg, rgba(250, 255, 252, 0.86), rgba(232, 249, 244, 0.58)), - rgba(255, 255, 255, 0.68); + linear-gradient(130deg, rgb(var(--surface-tint-warm-rgb) / 0.86), rgb(var(--surface-mint-rgb) / 0.58)), + rgb(var(--surface-rgb) / 0.68); cursor: default; } @@ -288,18 +288,18 @@ } .model-choice.selected { - border-color: rgba(53, 154, 185, 0.46); + border-color: rgb(var(--accent-ocean-rgb) / 0.46); background: linear-gradient( 130deg, - rgba(255, 255, 255, 0.94), - rgba(222, 249, 255, 0.76) 48%, - rgba(244, 239, 255, 0.78) + rgb(var(--surface-rgb) / 0.94), + rgb(var(--surface-tint-cool-rgb) / 0.76) 48%, + rgb(var(--surface-violet-rgb) / 0.78) ), - rgba(255, 255, 255, 0.84); + rgb(var(--surface-rgb) / 0.84); box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.88), - 0 16px 34px rgba(72, 148, 190, 0.16); + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.88), + 0 16px 34px rgb(var(--sky-deep-rgb) / 0.16); } .model-choice input { @@ -319,21 +319,21 @@ .model-choice-check { width: 24px; height: 24px; - border: 1px solid rgba(95, 144, 184, 0.28); - background: rgba(255, 255, 255, 0.68); + border: 1px solid rgb(var(--steel-mid-rgb) / 0.28); + background: rgb(var(--surface-rgb) / 0.68); color: transparent; } .model-choice.selected .model-choice-check { - border-color: rgba(43, 146, 112, 0.3); - background: linear-gradient(145deg, rgba(223, 255, 247, 0.96), rgba(84, 190, 157, 0.82)); - color: #ffffff; + border-color: rgb(var(--success-rgb) / 0.3); + background: linear-gradient(145deg, rgb(var(--surface-mint-rgb) / 0.96), rgba(84, 190, 157, 0.82)); + color: rgb(var(--highlight-rgb)); } .model-choice.installed .model-choice-check { - border-color: rgba(43, 146, 112, 0.26); - background: rgba(225, 255, 244, 0.76); - color: #2b926f; + border-color: rgb(var(--success-rgb) / 0.26); + background: rgb(var(--surface-mint-rgb) / 0.76); + color: rgb(var(--success-rgb)); } .model-choice-check svg { @@ -345,11 +345,11 @@ .model-choice-icon { width: 40px; height: 40px; - border: 1px solid rgba(91, 167, 207, 0.2); + border: 1px solid rgb(var(--accent-sky-rgb) / 0.2); background: - linear-gradient(145deg, rgba(255, 255, 255, 0.92), rgba(225, 246, 255, 0.74)), - rgba(255, 255, 255, 0.7); - color: #2d82a7; + linear-gradient(145deg, rgb(var(--surface-rgb) / 0.92), rgb(var(--surface-tint-cool-rgb) / 0.74)), + rgb(var(--surface-rgb) / 0.7); + color: rgb(var(--accent-strong-rgb)); } .model-choice-icon svg { @@ -364,13 +364,13 @@ } .model-choice-copy strong { - color: #1d344f; + color: rgb(var(--ink-slate-rgb)); font-size: 18px; font-weight: 900; } .model-choice-copy em { - color: #287c9f; + color: rgb(var(--accent-strong-rgb)); font-size: 12px; font-style: normal; font-weight: 860; @@ -379,7 +379,7 @@ } .model-choice-copy small { - color: #566f8c; + color: rgb(var(--slate-ocean-rgb)); font-size: 12px; font-weight: 640; line-height: 1.28; @@ -388,7 +388,7 @@ .model-choice-copy code, /* Reassures that a MiST-only install is complete, not half-finished. */ .model-core-scope { - color: rgba(30, 110, 158, 0.92) !important; + color: rgb(var(--ocean-rgb) / 0.92) !important; font-weight: 700; } @@ -403,10 +403,10 @@ .model-choice-size { align-self: start; - border: 1px solid rgba(143, 114, 207, 0.18); + border: 1px solid rgb(var(--prism-rgb) / 0.18); border-radius: 999px; padding: 5px 9px; - background: rgba(255, 255, 255, 0.62); + background: rgb(var(--surface-rgb) / 0.62); color: #5d4f91; font-size: 11px; font-weight: 900; @@ -414,9 +414,9 @@ } .model-choice.installed .model-choice-size { - border-color: rgba(43, 146, 112, 0.2); - background: rgba(232, 255, 247, 0.72); - color: #2b7d64; + border-color: rgb(var(--success-rgb) / 0.2); + background: rgb(var(--surface-mint-rgb) / 0.72); + color: rgb(var(--text-success-rgb)); } .model-setup-side { @@ -432,12 +432,12 @@ align-content: start; gap: 7px; min-width: 0; - border: 1px solid rgba(112, 169, 205, 0.22); + border: 1px solid rgb(var(--accent-sky-rgb) / 0.22); border-radius: 18px; padding: 14px; background: - linear-gradient(145deg, rgba(255, 255, 255, 0.82), rgba(235, 249, 255, 0.56)), - rgba(255, 255, 255, 0.64); + linear-gradient(145deg, rgb(var(--surface-rgb) / 0.82), rgb(var(--surface-tint-rgb) / 0.56)), + rgb(var(--surface-rgb) / 0.64); } .model-core-card span, @@ -446,7 +446,7 @@ display: flex; align-items: center; gap: 8px; - color: #287c9f; + color: rgb(var(--accent-strong-rgb)); font-size: 11px; font-weight: 900; letter-spacing: 0.1em; @@ -459,22 +459,22 @@ } .model-core-card strong { - color: #1d344f; + color: rgb(var(--ink-slate-rgb)); font-size: 18px; font-weight: 900; } .model-core-card.installed { - border-color: rgba(43, 146, 112, 0.2); + border-color: rgb(var(--success-rgb) / 0.2); background: - linear-gradient(145deg, rgba(250, 255, 252, 0.86), rgba(233, 250, 245, 0.6)), - rgba(255, 255, 255, 0.62); + linear-gradient(145deg, rgb(var(--surface-tint-warm-rgb) / 0.86), rgb(var(--surface-mint-rgb) / 0.6)), + rgb(var(--surface-rgb) / 0.62); } .model-core-card.installed small, .model-choice.installed .model-choice-copy code, .model-core-card code { - color: #2b7d64; + color: rgb(var(--text-success-rgb)); } .model-access-card { @@ -483,7 +483,7 @@ .model-access-card p { margin: 0; - color: #566f8c; + color: rgb(var(--slate-ocean-rgb)); font-size: 12px; font-weight: 640; line-height: 1.45; @@ -501,19 +501,19 @@ justify-content: space-between; gap: 8px; min-width: 0; - border: 1px solid rgba(88, 150, 194, 0.2); + border: 1px solid rgb(var(--sky-deep-rgb) / 0.2); border-radius: 12px; padding: 5px 9px; - background: rgba(255, 255, 255, 0.58); - color: #276f9b; + background: rgb(var(--surface-rgb) / 0.58); + color: rgb(var(--ocean-rgb)); font-size: 11px; font-weight: 860; text-decoration: none; } .model-access-links a:hover { - border-color: rgba(58, 143, 183, 0.32); - background: rgba(255, 255, 255, 0.82); + border-color: rgb(var(--line-strong-rgb) / 0.32); + background: rgb(var(--surface-rgb) / 0.82); } .model-access-links svg { @@ -526,16 +526,16 @@ display: grid; gap: 8px; min-width: 0; - border: 1px solid rgba(174, 215, 239, 0.42); + border: 1px solid rgb(var(--sky-mist-rgb) / 0.42); border-radius: 22px; padding: 10px 12px; background: - radial-gradient(circle at 20% 0%, rgba(255, 255, 255, 0.98), transparent 42%), - linear-gradient(135deg, rgba(248, 253, 255, 0.92), rgba(235, 248, 255, 0.76)), - rgba(255, 255, 255, 0.82); + radial-gradient(circle at 20% 0%, rgb(var(--surface-rgb) / 0.98), transparent 42%), + linear-gradient(135deg, rgb(var(--surface-tint-warm-rgb) / 0.92), rgb(var(--surface-tint-rgb) / 0.76)), + rgb(var(--surface-rgb) / 0.82); box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.88), - 0 16px 42px rgba(58, 127, 180, 0.14); + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.88), + 0 16px 42px rgb(var(--azure-rgb) / 0.14); backdrop-filter: blur(18px) saturate(1.12); } @@ -547,7 +547,7 @@ } .model-progress-heading span { - color: #287c9f; + color: rgb(var(--accent-strong-rgb)); font-size: 11px; font-weight: 900; letter-spacing: 0.12em; @@ -555,7 +555,7 @@ } .model-progress-heading strong { - color: #1d344f; + color: rgb(var(--ink-slate-rgb)); font-size: 12px; font-weight: 860; } @@ -565,7 +565,7 @@ position: relative; overflow: hidden; border-radius: 999px; - background: rgba(98, 132, 174, 0.14); + background: rgb(var(--steel-mid-rgb) / 0.14); } .model-progress-meter { @@ -583,7 +583,7 @@ } .model-progress-meter span { - background: linear-gradient(90deg, #55c7d9, #69d8bd 44%, #9a7dd5); + background: linear-gradient(90deg, #55c7d9, rgb(var(--aurora-rgb)) 44%, rgb(var(--prism-rgb))); box-shadow: 0 0 18px rgba(85, 199, 217, 0.35); } @@ -598,10 +598,10 @@ gap: 9px; align-items: center; min-width: 0; - border: 1px solid rgba(115, 153, 198, 0.14); + border: 1px solid rgb(var(--steel-soft-rgb) / 0.14); border-radius: 14px; padding: 8px; - background: rgba(255, 255, 255, 0.5); + background: rgb(var(--surface-rgb) / 0.5); } .model-progress-icon { @@ -610,8 +610,8 @@ width: 28px; height: 28px; border-radius: 10px; - background: rgba(231, 247, 255, 0.72); - color: #2d82a7; + background: rgb(var(--surface-tint-cool-rgb) / 0.72); + color: rgb(var(--accent-strong-rgb)); } .model-progress-icon svg { @@ -625,12 +625,12 @@ .model-progress-row.complete .model-progress-icon, .model-progress-row.skipped .model-progress-icon { - background: rgba(223, 255, 247, 0.82); - color: #2b9270; + background: rgb(var(--surface-mint-rgb) / 0.82); + color: rgb(var(--success-rgb)); } .model-progress-row.error .model-progress-icon { - background: rgba(255, 233, 232, 0.82); + background: rgb(var(--surface-blush-rgb) / 0.82); color: var(--danger); } @@ -648,14 +648,14 @@ } .model-progress-copy strong { - color: #24384f; + color: rgb(var(--ink-slate-rgb)); font-size: 12px; font-weight: 860; } .model-progress-copy small, .model-progress-size { - color: #6f8299; + color: rgb(var(--muted-rgb)); font-size: 10px; font-weight: 720; } @@ -670,15 +670,15 @@ } .model-progress-line i { - background: linear-gradient(90deg, #55c7d9, #69d8bd 54%, #9a7dd5); + background: linear-gradient(90deg, #55c7d9, rgb(var(--aurora-rgb)) 54%, rgb(var(--prism-rgb))); } .model-setup-error { margin: -4px 0 0; - border: 1px solid rgba(184, 91, 89, 0.2); + border: 1px solid rgb(var(--danger-rgb) / 0.2); border-radius: 14px; padding: 10px 12px; - background: rgba(255, 247, 246, 0.78); + background: rgb(var(--surface-blush-rgb) / 0.78); color: var(--danger); font-size: 12px; font-weight: 760; @@ -690,16 +690,16 @@ justify-content: flex-end; gap: 10px; min-width: max-content; - border: 1px solid rgba(174, 215, 239, 0.46); + border: 1px solid rgb(var(--sky-mist-rgb) / 0.46); border-radius: 22px; padding: 10px; background: - radial-gradient(circle at 24% 8%, rgba(255, 255, 255, 0.98), transparent 44%), - linear-gradient(135deg, rgba(248, 253, 255, 0.94), rgba(235, 248, 255, 0.8)), - rgba(255, 255, 255, 0.84); + radial-gradient(circle at 24% 8%, rgb(var(--surface-rgb) / 0.98), transparent 44%), + linear-gradient(135deg, rgb(var(--surface-tint-warm-rgb) / 0.94), rgb(var(--surface-tint-rgb) / 0.8)), + rgb(var(--surface-rgb) / 0.84); box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.88), - 0 18px 46px rgba(58, 127, 180, 0.18); + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.88), + 0 18px 46px rgb(var(--azure-rgb) / 0.18); backdrop-filter: blur(18px) saturate(1.12); } @@ -709,17 +709,17 @@ .model-setup-actions button:hover { padding: 0 10px; - border-color: rgba(86, 139, 218, 0.3); + border-color: rgb(var(--steel-rgb) / 0.3); background: - radial-gradient(circle at 30% 18%, rgba(255, 255, 255, 0.9), transparent 42%), - linear-gradient(135deg, rgba(233, 244, 255, 0.95), rgba(255, 255, 255, 0.82)); + radial-gradient(circle at 30% 18%, rgb(var(--surface-rgb) / 0.9), transparent 42%), + linear-gradient(135deg, rgb(var(--surface-tint-cool-rgb) / 0.95), rgb(var(--surface-rgb) / 0.82)); color: var(--accent-strong); font-weight: 800; } .model-setup-actions button:hover:not(:disabled) { - border-color: rgba(86, 139, 218, 0.5); - box-shadow: 0 8px 18px rgba(74, 145, 194, 0.14); + border-color: rgb(var(--steel-rgb) / 0.5); + box-shadow: 0 8px 18px rgb(var(--sky-deep-rgb) / 0.14); transform: translateY(-1px); } @@ -824,14 +824,14 @@ rectangle whose top/bottom can never overlap the rounded corners — and neither can the scrollbar that lives inside it. */ padding: 28px 0; - border: 1px solid rgba(101, 145, 190, 0.24); + border: 1px solid rgb(var(--steel-soft-rgb) / 0.24); border-radius: 28px; background: - radial-gradient(circle at 18% 0%, rgba(255, 255, 255, 0.96), transparent 42%), - linear-gradient(145deg, rgba(255, 255, 255, 0.94), rgba(232, 246, 255, 0.88)); + radial-gradient(circle at 18% 0%, rgb(var(--surface-rgb) / 0.96), transparent 42%), + linear-gradient(145deg, rgb(var(--surface-rgb) / 0.94), rgb(var(--surface-tint-cool-rgb) / 0.88)); box-shadow: - 0 32px 80px rgba(54, 91, 132, 0.2), - inset 0 1px 0 rgba(255, 255, 255, 0.8); + 0 32px 80px rgb(var(--shadow-rgb) / 0.2), + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.8); transform-origin: center; animation: settings-modal-in 520ms cubic-bezier(0.16, 1, 0.3, 1) both; } @@ -862,13 +862,13 @@ border-radius: 999px; background: linear-gradient( 180deg, - rgba(255, 255, 255, 0.98), - rgba(150, 224, 255, 0.92) 44%, + rgb(var(--surface-rgb) / 0.98), + rgb(var(--accent-pale-rgb) / 0.92) 44%, rgba(54, 150, 206, 0.82) ); background-clip: padding-box; box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.92), + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.92), 0 0 0 rgba(83, 212, 255, 0); filter: saturate(1) brightness(1); transition: @@ -881,16 +881,16 @@ border-width: 3px; filter: saturate(1.2) brightness(1.1); box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.96), - 0 0 14px rgba(101, 226, 255, 0.6); + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.96), + 0 0 14px rgb(var(--glow-rgb) / 0.6); } .settings-modal-body::-webkit-scrollbar-thumb:active { border-width: 2px; filter: saturate(1.35) brightness(1.18); box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 1), - 0 0 22px rgba(120, 232, 255, 0.78); + inset 0 1px 0 rgb(var(--highlight-rgb) / 1), + 0 0 22px rgb(var(--glow-rgb) / 0.78); } @keyframes settings-overlay-in { @@ -952,10 +952,10 @@ } .settings-field { - border: 1px solid rgba(123, 158, 190, 0.2); + border: 1px solid rgb(var(--line-rgb) / 0.2); border-radius: 18px; padding: 14px; - background: rgba(255, 255, 255, 0.54); + background: rgb(var(--surface-rgb) / 0.54); } .settings-field.developer-mode-field { @@ -967,9 +967,9 @@ } .settings-field.developer-mode-field:hover { - border-color: rgba(79, 174, 202, 0.4); - background: rgba(238, 250, 255, 0.78); - box-shadow: 0 8px 22px rgba(82, 126, 188, 0.12); + border-color: rgb(var(--accent-muted-rgb) / 0.4); + background: rgb(var(--surface-tint-rgb) / 0.78); + box-shadow: 0 8px 22px rgb(var(--steel-mid-rgb) / 0.12); } .settings-model-setup-field { @@ -1014,11 +1014,11 @@ it should be until the re-index runs. Scoped rather than a global token, since the palette-wide tokenization is a separate piece of work. */ .settings-reindex-field { - --warning-strong: #b4690e; - border: 1px solid rgba(180, 105, 14, 0.28); + --warning-strong: rgb(var(--warning-rgb)); + border: 1px solid rgb(var(--warning-rgb) / 0.28); border-radius: 12px; padding: 12px 14px; - background: rgba(180, 105, 14, 0.06); + background: rgb(var(--warning-rgb) / 0.06); } .settings-reindex-field .settings-model-setup-copy svg { @@ -1063,29 +1063,29 @@ } .update-badge { - border: 1px solid rgba(105, 188, 224, 0.28); + border: 1px solid rgb(var(--accent-soft-rgb) / 0.28); border-radius: 999px; padding: 5px 10px; - background: rgba(240, 250, 255, 0.72); - color: #2a7a9f; + background: rgb(var(--surface-tint-rgb) / 0.72); + color: rgb(var(--accent-strong-rgb)); font-size: 10px; font-weight: 900; text-transform: uppercase; } .update-version-badge { - border: 1px solid rgba(31, 68, 103, 0.1); + border: 1px solid rgb(var(--ink-navy-rgb) / 0.1); border-radius: 999px; padding: 5px 10px; - background: rgba(255, 255, 255, 0.66); + background: rgb(var(--surface-rgb) / 0.66); color: var(--ink-soft); font-size: 10px; font-weight: 900; } .update-badge.available { - border-color: rgba(42, 148, 111, 0.26); - background: rgba(227, 248, 238, 0.78); + border-color: rgb(var(--success-rgb) / 0.26); + background: rgb(var(--surface-mint-rgb) / 0.78); color: var(--success); } @@ -1104,10 +1104,10 @@ } .settings-update-status span { - border: 1px solid rgba(105, 188, 224, 0.18); + border: 1px solid rgb(var(--accent-soft-rgb) / 0.18); border-radius: 999px; padding: 4px 8px; - background: rgba(255, 255, 255, 0.54); + background: rgb(var(--surface-rgb) / 0.54); color: var(--muted); font-size: 10px; font-weight: 800; @@ -1117,13 +1117,122 @@ display: -webkit-box; max-height: 96px; overflow: hidden; - border-left: 3px solid rgba(54, 122, 178, 0.26); + border-left: 3px solid rgb(var(--azure-rgb) / 0.26); padding-left: 10px; margin: 0 !important; -webkit-box-orient: vertical; -webkit-line-clamp: 5; } +/* A glance at recent failures, not a log viewer — capped in the markup at 12 rows + and scrollable, so a bad session cannot push the rest of Settings off-screen. */ +.settings-diagnostics-log { + display: grid; + gap: 2px; + max-height: 190px; + overflow-y: auto; + border: 1px solid var(--line); + border-radius: 14px; + padding: 8px; + background: rgb(var(--surface-rgb) / 0.42); + font-size: 11px; +} + +.settings-diagnostics-row { + display: grid; + grid-template-columns: auto 44px minmax(0, 1fr); + gap: 8px; + align-items: baseline; + border-radius: 8px; + padding: 4px 6px; +} + +.settings-diagnostics-row.is-error { + background: rgb(var(--danger-rgb) / 0.1); +} + +.settings-diagnostics-row.is-warn { + background: rgb(var(--glint-rgb) / 0.12); +} + +.settings-diagnostics-when { + color: var(--faint); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.settings-diagnostics-level { + color: var(--muted); + font-weight: 800; + font-size: 9.5px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +/* Messages carry file paths, so they wrap rather than ellipsis — a truncated path + is the one part of a diagnostic that is useless. */ +.settings-diagnostics-message { + color: var(--soft-ink); + overflow-wrap: anywhere; +} + +.settings-update-progress { + display: flex; + flex-direction: column; + gap: 6px; +} + +.settings-update-progress small { + color: var(--muted); + font-variant-numeric: tabular-nums; +} + +.settings-update-progress-track { + overflow: hidden; + height: 6px; + border-radius: 999px; + background: var(--line); +} + +.settings-update-progress-track > span { + display: block; + width: 0; + height: 100%; + border-radius: inherit; + background: var(--accent); + transition: width 160ms ease-out; +} + +/* The release host does not always send a Content-Length, and a bar parked at 0% + for a 100 MB download reads as a hang. Sweep instead of claiming a percentage. */ +.settings-update-progress-track.is-indeterminate > span { + width: 40%; + animation: settings-update-sweep 1.1s ease-in-out infinite; +} + +@keyframes settings-update-sweep { + from { + transform: translateX(-100%); + } + to { + transform: translateX(250%); + } +} + +@media (prefers-reduced-motion: reduce) { + .settings-update-progress-track > span { + transition: none; + } + + /* No sweep to fall back on, so fill the track and let the byte count carry the + progress instead of leaving an ambiguous sliver on screen. */ + .settings-update-progress-track.is-indeterminate > span { + width: 100%; + animation: none; + opacity: 0.4; + } +} + .settings-update-actions { align-items: center; } @@ -1232,9 +1341,9 @@ .settings-engine-list button:hover:not(:disabled), .settings-engine-list button.selected { - border-color: rgba(54, 122, 178, 0.34); + border-color: rgb(var(--azure-rgb) / 0.34); background-position: 10% 10%; - box-shadow: 0 10px 24px rgba(70, 124, 180, 0.12); + box-shadow: 0 10px 24px rgb(var(--azure-rgb) / 0.12); transform: translateY(-1px); } @@ -1253,22 +1362,22 @@ display: grid; gap: 12px; margin-top: 14px; - border: 1px solid rgba(125, 211, 252, 0.22); + border: 1px solid rgb(var(--sky-rgb) / 0.22); border-radius: 18px; padding: 14px; background: - radial-gradient(circle at 18% 8%, rgba(255, 255, 255, 0.82), transparent 38%), - linear-gradient(145deg, rgba(239, 249, 255, 0.58), rgba(255, 255, 255, 0.42)); + radial-gradient(circle at 18% 8%, rgb(var(--surface-rgb) / 0.82), transparent 38%), + linear-gradient(145deg, rgb(var(--surface-tint-rgb) / 0.58), rgb(var(--surface-rgb) / 0.42)); } .settings-shortcuts h2 { - color: #20344c; + color: rgb(var(--ink-slate-rgb)); font-size: 15px; } .settings-shortcuts p { margin: 3px 0 0; - color: #6f8299; + color: rgb(var(--muted-rgb)); font-size: 11px; } @@ -1305,10 +1414,10 @@ align-items: center; gap: 10px; min-width: 0; - border: 1px solid rgba(125, 158, 205, 0.16); + border: 1px solid rgb(var(--slate-soft-rgb) / 0.16); border-radius: 12px; padding: 7px 10px; - background: rgba(255, 255, 255, 0.56); + background: rgb(var(--surface-rgb) / 0.56); } .settings-shortcut-row kbd { @@ -1316,13 +1425,13 @@ width: 135px; align-items: center; justify-content: center; - border: 1px solid rgba(105, 181, 216, 0.28); + border: 1px solid rgb(var(--accent-soft-rgb) / 0.28); border-radius: 8px; padding: 3px 6px; background: - linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(229, 248, 255, 0.72)), - rgba(255, 255, 255, 0.7); - color: #24779d; + linear-gradient(180deg, rgb(var(--surface-rgb) / 0.92), rgb(var(--surface-tint-cool-rgb) / 0.72)), + rgb(var(--surface-rgb) / 0.7); + color: rgb(var(--ocean-rgb)); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 10px; font-weight: 800; @@ -1331,7 +1440,7 @@ .settings-shortcut-row span { overflow: hidden; - color: #24384f; + color: rgb(var(--ink-slate-rgb)); font-size: 11px; font-weight: 780; text-align: left; diff --git a/src/renderer/src/assets/styles/shared-overrides.css b/src/renderer/src/assets/styles/shared-overrides.css index 2224fe8..2e5f0aa 100644 --- a/src/renderer/src/assets/styles/shared-overrides.css +++ b/src/renderer/src/assets/styles/shared-overrides.css @@ -5,13 +5,13 @@ .empty-state { min-height: 172px; - border: 1px dashed rgba(115, 148, 194, 0.28); + border: 1px dashed rgb(var(--steel-soft-rgb) / 0.28); border-radius: 14px; - background: rgba(255, 255, 255, 0.34); + background: rgb(var(--surface-rgb) / 0.34); } .empty-row { - border: 1px dashed rgba(115, 148, 194, 0.22); + border: 1px dashed rgb(var(--steel-soft-rgb) / 0.22); border-radius: 12px; padding: 16px; color: var(--muted); @@ -37,11 +37,11 @@ padding: 0; opacity: 0; pointer-events: none; - color: #5e87c3; + color: rgb(var(--text-steel-rgb)); background: - radial-gradient(circle at 34% 20%, rgba(255, 255, 255, 0.98), transparent 38%), - radial-gradient(circle at 70% 82%, rgba(168, 237, 255, 0.36), transparent 46%), - rgba(255, 255, 255, 0.72); + radial-gradient(circle at 34% 20%, rgb(var(--surface-rgb) / 0.98), transparent 38%), + radial-gradient(circle at 70% 82%, rgb(var(--sky-pale-rgb) / 0.36), transparent 46%), + rgb(var(--surface-rgb) / 0.72); transform: translateX(-50%); transition: opacity 320ms ease 160ms, @@ -63,9 +63,9 @@ } .panel-icon-toggle:hover { - background: rgba(255, 255, 255, 0.94); + background: rgb(var(--surface-rgb) / 0.94); color: var(--accent-strong); - box-shadow: 0 12px 24px rgba(84, 130, 199, 0.16); + box-shadow: 0 12px 24px rgb(var(--steel-vivid-rgb) / 0.16); transform: translate(-50%, -1px); } @@ -73,14 +73,14 @@ 0%, 100% { box-shadow: - 0 14px 30px rgba(84, 130, 199, 0.13), - 0 0 0 0 rgba(172, 236, 255, 0.32); + 0 14px 30px rgb(var(--steel-vivid-rgb) / 0.13), + 0 0 0 0 rgb(var(--glow-pale-rgb) / 0.32); } 50% { box-shadow: 0 18px 40px rgba(84, 160, 199, 0.22), - 0 0 0 8px rgba(172, 236, 255, 0); + 0 0 0 8px rgb(var(--glow-pale-rgb) / 0); } } @@ -124,13 +124,13 @@ left: 14px; z-index: 6; gap: 10px; - border: 1px solid rgba(133, 158, 193, 0.18); + border: 1px solid rgb(var(--slate-soft-rgb) / 0.18); border-radius: 14px; padding: 12px; background: - radial-gradient(circle at 14% 0%, rgba(255, 255, 255, 0.92), transparent 40%), - rgba(250, 253, 255, 0.94); - box-shadow: 0 18px 44px rgba(75, 114, 170, 0.18); + radial-gradient(circle at 14% 0%, rgb(var(--surface-rgb) / 0.92), transparent 40%), + rgb(var(--surface-tint-warm-rgb) / 0.94); + box-shadow: 0 18px 44px rgb(var(--steel-deep-rgb) / 0.18); } .model-island.compact-model-island { @@ -139,20 +139,20 @@ left: auto; width: min(248px, calc(100% - 28px)); gap: 0; - border-color: rgba(105, 188, 224, 0.24); + border-color: rgb(var(--accent-soft-rgb) / 0.24); border-radius: 999px; padding: 9px 10px 10px; background: - radial-gradient(circle at 18% 12%, rgba(255, 255, 255, 0.98), transparent 42%), - linear-gradient(145deg, rgba(240, 252, 255, 0.96), rgba(255, 255, 255, 0.78)); + radial-gradient(circle at 18% 12%, rgb(var(--surface-rgb) / 0.98), transparent 42%), + linear-gradient(145deg, rgb(var(--surface-tint-rgb) / 0.96), rgb(var(--surface-rgb) / 0.78)); box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.86), - 0 14px 34px rgba(72, 151, 190, 0.16); + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.86), + 0 14px 34px rgb(var(--sky-deep-rgb) / 0.16); } .model-island.compact-model-island label { gap: 4px; - color: #2a7a9f; + color: rgb(var(--accent-strong-rgb)); font-size: 10px; font-weight: 860; letter-spacing: 0.04em; @@ -161,17 +161,17 @@ .model-island.compact-model-island select { height: 30px; - border-color: rgba(99, 174, 214, 0.22); + border-color: rgb(var(--accent-sky-rgb) / 0.22); border-radius: 999px; background: - linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(234, 249, 255, 0.78)), - rgba(255, 255, 255, 0.84); + linear-gradient(180deg, rgb(var(--surface-rgb) / 0.94), rgb(var(--surface-tint-rgb) / 0.78)), + rgb(var(--surface-rgb) / 0.84); color: var(--ink); font-size: 12px; font-weight: 820; box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.9), - 0 8px 18px rgba(74, 145, 194, 0.1); + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.9), + 0 8px 18px rgb(var(--sky-deep-rgb) / 0.1); } .model-heading { @@ -198,10 +198,10 @@ .model-heading span { max-width: 156px; overflow: hidden; - border: 1px solid rgba(105, 139, 190, 0.16); + border: 1px solid rgb(var(--steel-mid-rgb) / 0.16); border-radius: 999px; padding: 4px 8px; - background: rgba(255, 255, 255, 0.62); + background: rgb(var(--surface-rgb) / 0.62); text-overflow: ellipsis; white-space: nowrap; } @@ -219,11 +219,11 @@ } .frozen-tab { - border-color: rgba(125, 211, 252, 0.34); + border-color: rgb(var(--sky-rgb) / 0.34); background: - radial-gradient(circle at 80% 12%, rgba(255, 255, 255, 0.98), transparent 24%), - linear-gradient(135deg, #ecfeff 0%, #bae6fd 38%, #e0f2fe 66%, #ffffff 100%) !important; - color: #075985; + radial-gradient(circle at 80% 12%, rgb(var(--surface-rgb) / 0.98), transparent 24%), + linear-gradient(135deg, rgb(var(--surface-tint-rgb)) 0%, rgb(var(--sky-pale-rgb)) 38%, rgb(var(--surface-sky-rgb)) 66%, rgb(var(--surface-rgb)) 100%) !important; + color: rgb(var(--marine-rgb)); isolation: isolate; transition: transform 90ms ease, @@ -234,8 +234,8 @@ } .frozen-tab:hover { - border-color: rgba(14, 165, 233, 0.58); - color: #082f49; + border-color: rgb(var(--accent-vivid-rgb) / 0.58); + color: rgb(var(--ink-deep-rgb)); transform: translateY(-1px); } @@ -258,11 +258,11 @@ max-width: min(100%, 260px); height: 26px; overflow: hidden; - border: 1px solid rgba(145, 166, 195, 0.18); + border: 1px solid rgb(var(--slate-rgb) / 0.18); border-radius: 999px; padding: 4px 8px; - background: rgba(255, 255, 255, 0.58); - color: #566b86; + background: rgb(var(--surface-rgb) / 0.58); + color: rgb(var(--slate-ocean-rgb)); font-size: 11px; font-weight: 700; box-shadow: none; @@ -271,8 +271,8 @@ } .citation-list button:hover { - border-color: rgba(79, 174, 202, 0.34); - background: rgba(255, 255, 255, 0.9); + border-color: rgb(var(--accent-muted-rgb) / 0.34); + background: rgb(var(--surface-rgb) / 0.9); color: var(--accent-strong); } @@ -282,19 +282,19 @@ z-index: 30; display: grid; place-items: center; - background: rgba(219, 232, 249, 0.36); + background: rgb(var(--surface-sky-rgb) / 0.36); backdrop-filter: blur(18px); } .collection-dialog { width: min(460px, calc(100vw - 40px)); - border: 1px solid rgba(118, 146, 187, 0.24); + border: 1px solid rgb(var(--line-rgb) / 0.24); border-radius: 18px; padding: 18px; background: - radial-gradient(circle at 18% 0%, rgba(255, 255, 255, 0.96), transparent 36%), - rgba(250, 253, 255, 0.92); - box-shadow: 0 24px 70px rgba(75, 114, 170, 0.22); + radial-gradient(circle at 18% 0%, rgb(var(--surface-rgb) / 0.96), transparent 36%), + rgb(var(--surface-tint-warm-rgb) / 0.92); + box-shadow: 0 24px 70px rgb(var(--steel-deep-rgb) / 0.22); } .collection-dialog header, @@ -373,19 +373,19 @@ min-width: 0; place-items: center; gap: 4px; - border-color: rgba(122, 154, 196, 0.18); + border-color: rgb(var(--line-rgb) / 0.18); border-radius: 12px; padding: 6px; - background: rgba(255, 255, 255, 0.54); + background: rgb(var(--surface-rgb) / 0.54); box-shadow: none; } .icon-picker button.active, .icon-picker button:hover { - border-color: rgba(79, 174, 202, 0.38); + border-color: rgb(var(--accent-muted-rgb) / 0.38); background: - radial-gradient(circle at 30% 16%, rgba(255, 255, 255, 0.96), transparent 42%), - linear-gradient(145deg, rgba(232, 251, 255, 0.9), rgba(255, 255, 255, 0.72)); + radial-gradient(circle at 30% 16%, rgb(var(--surface-rgb) / 0.96), transparent 42%), + linear-gradient(145deg, rgb(var(--surface-tint-cool-rgb) / 0.9), rgb(var(--surface-rgb) / 0.72)); color: var(--accent-strong); } @@ -405,7 +405,7 @@ .delete-copy { margin: 20px 0; - color: #33455f; + color: rgb(var(--soft-ink-rgb)); font-size: 14px; line-height: 1.5; } @@ -417,34 +417,38 @@ .primary-button, .danger-primary { - border-color: rgba(76, 151, 190, 0.4); + border-color: rgb(var(--sky-deep-rgb) / 0.4); background: - radial-gradient(circle at 28% 16%, rgba(255, 255, 255, 0.95), transparent 36%), + radial-gradient(circle at 28% 16%, rgb(var(--surface-rgb) / 0.95), transparent 36%), linear-gradient( 145deg, - rgba(229, 252, 255, 0.96), - rgba(118, 196, 229, 0.94) 48%, - rgba(54, 129, 188, 0.92) + rgb(var(--surface-tint-cool-rgb) / 0.96), + rgb(var(--accent-light-rgb) / 0.94) 48%, + rgb(var(--azure-rgb) / 0.92) ); - color: #ffffff; + color: rgb(var(--highlight-rgb)); box-shadow: - inset 0 1px 0 rgba(255, 255, 255, 0.62), - 0 12px 26px rgba(52, 139, 184, 0.16); - text-shadow: 0 1px 8px rgba(26, 80, 121, 0.25); + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.62), + 0 12px 26px rgb(var(--accent-ocean-rgb) / 0.16); + text-shadow: 0 1px 8px rgb(var(--ink-navy-rgb) / 0.25); } .danger-primary { - border-color: rgba(184, 91, 89, 0.25); - background: linear-gradient(180deg, #d87a78, #b85b59); + border-color: rgb(var(--danger-rgb) / 0.25); + background: linear-gradient(180deg, #d87a78, rgb(var(--danger-rgb))); } button:disabled, button:disabled:hover { - border-color: rgba(121, 144, 176, 0.18); + border-color: rgb(var(--slate-cool-rgb) / 0.18); background: - radial-gradient(circle at 32% 18%, rgba(255, 255, 255, 0.7), transparent 42%), - linear-gradient(145deg, rgba(233, 239, 246, 0.72), rgba(218, 228, 238, 0.62)); - color: rgba(77, 95, 120, 0.58); + radial-gradient(circle at 32% 18%, rgb(var(--surface-rgb) / 0.7), transparent 42%), + linear-gradient( + 145deg, + rgb(var(--surface-sky-rgb) / 0.72), + rgb(var(--surface-tint-rgb) / 0.62) + ); + color: rgb(var(--slate-steel-rgb) / 0.58); box-shadow: none; cursor: not-allowed; opacity: 0.5; @@ -453,7 +457,7 @@ button:disabled:hover { .history-controls button:disabled, .history-controls button:disabled:hover { - color: rgba(92, 111, 138, 0.48); + color: rgb(var(--slate-mid-rgb) / 0.48); } @media (max-width: 1120px) { @@ -462,7 +466,7 @@ button:disabled:hover { } .saved-shelves .iceberg-band { - border-top: 1px solid rgba(143, 165, 196, 0.18); + border-top: 1px solid rgb(var(--slate-rgb) / 0.18); border-left: 0; } @@ -486,7 +490,7 @@ button:disabled:hover { } .crystallizer-dock { - border-top: 1px solid rgba(125, 211, 252, 0.26); + border-top: 1px solid rgb(var(--sky-rgb) / 0.26); border-left: 0; } diff --git a/src/renderer/src/assets/styles/start-page.css b/src/renderer/src/assets/styles/start-page.css index f4cd21c..7ad3e56 100644 --- a/src/renderer/src/assets/styles/start-page.css +++ b/src/renderer/src/assets/styles/start-page.css @@ -48,14 +48,14 @@ flex: 1 1 auto; min-width: 0; height: 48px; - border: 1px solid rgba(145, 166, 195, 0.28); + border: 1px solid rgb(var(--slate-rgb) / 0.28); border-radius: 14px; padding: 0 18px; - background: rgba(255, 255, 255, 0.78); + background: rgb(var(--surface-rgb) / 0.78); color: var(--ink); font-size: 14px; font-weight: 600; - box-shadow: 0 12px 30px rgba(94, 132, 185, 0.1); + box-shadow: 0 12px 30px rgb(var(--steel-mid-rgb) / 0.1); transition: border-color 160ms ease, box-shadow 160ms ease, @@ -63,9 +63,9 @@ } .start-search input:focus { - border-color: rgba(86, 139, 218, 0.5); - background: rgba(255, 255, 255, 0.94); - box-shadow: 0 14px 34px rgba(74, 145, 194, 0.16); + border-color: rgb(var(--steel-rgb) / 0.5); + background: rgb(var(--surface-rgb) / 0.94); + box-shadow: 0 14px 34px rgb(var(--sky-deep-rgb) / 0.16); outline: none; } @@ -96,7 +96,7 @@ max-height: clamp(112px, 22vh, 190px); overflow: auto; padding: 2px 4px 10px; - scrollbar-color: rgba(82, 178, 219, 0.34) transparent; + scrollbar-color: rgb(var(--accent-bright-rgb) / 0.34) transparent; scrollbar-width: thin; } @@ -111,8 +111,8 @@ .start-portals::-webkit-scrollbar-thumb { border-radius: 999px; background: - linear-gradient(180deg, rgba(255, 255, 255, 0.74), rgba(255, 255, 255, 0.22)), - rgba(82, 178, 219, 0.34); + linear-gradient(180deg, rgb(var(--surface-rgb) / 0.74), rgb(var(--surface-rgb) / 0.22)), + rgb(var(--accent-bright-rgb) / 0.34); } @media (max-height: 760px) { @@ -152,17 +152,17 @@ border-radius: 16px; padding: 13px 10px 10px; border: 1px solid - color-mix(in srgb, var(--portal-tint, var(--accent)) 28%, rgba(104, 140, 190, 0.22)); + color-mix(in srgb, var(--portal-tint, var(--accent)) 28%, rgb(var(--steel-mid-rgb) / 0.22)); background: - radial-gradient(circle at 50% 0%, rgba(255, 255, 255, 0.96), transparent 48%), + radial-gradient(circle at 50% 0%, rgb(var(--surface-rgb) / 0.96), transparent 48%), radial-gradient( circle at 88% 90%, color-mix(in srgb, var(--portal-tint, var(--accent)) 18%, transparent), transparent 42% ), - rgba(255, 255, 255, 0.58); + rgb(var(--surface-rgb) / 0.58); color: var(--ink); - box-shadow: 0 12px 28px rgba(94, 132, 185, 0.08); + box-shadow: 0 12px 28px rgb(var(--steel-mid-rgb) / 0.08); transition: transform 160ms ease, border-color 160ms ease, @@ -174,10 +174,10 @@ border-color: color-mix( in srgb, var(--portal-tint, var(--accent)) 46%, - rgba(255, 255, 255, 0.46) + rgb(var(--edge-rgb) / 0.46) ); box-shadow: 0 6px 14px - color-mix(in srgb, var(--portal-tint, var(--accent)) 18%, rgba(62, 132, 163, 0.12)); + color-mix(in srgb, var(--portal-tint, var(--accent)) 18%, rgb(var(--shadow-soft-rgb) / 0.12)); transform: translateY(-2px); } @@ -188,8 +188,8 @@ height: 40px; border-radius: 12px; background: - radial-gradient(circle at 35% 22%, #ffffff, transparent 44%), - linear-gradient(145deg, #dff9ff, #ffffff); + radial-gradient(circle at 35% 22%, rgb(var(--surface-rgb)), transparent 44%), + linear-gradient(145deg, rgb(var(--surface-tint-cool-rgb)), rgb(var(--surface-rgb))); color: var(--accent-strong); font-family: var(--font-old); font-size: 18px; @@ -232,9 +232,9 @@ } .hub-shortcut.drop-target .hub-launch { - border-color: color-mix(in srgb, var(--portal-tint, var(--accent)) 54%, rgba(255, 255, 255, 0.5)); + border-color: color-mix(in srgb, var(--portal-tint, var(--accent)) 54%, rgb(var(--edge-rgb) / 0.5)); box-shadow: - 0 18px 36px color-mix(in srgb, var(--portal-tint, var(--accent)) 22%, rgba(62, 132, 163, 0.12)), + 0 18px 36px color-mix(in srgb, var(--portal-tint, var(--accent)) 22%, rgb(var(--shadow-soft-rgb) / 0.12)), 0 0 0 4px color-mix(in srgb, var(--portal-tint, var(--accent)) 18%, transparent); transform: translateY(-2px) scale(1.018); } @@ -254,19 +254,19 @@ border-color: color-mix( in srgb, var(--portal-tint, var(--accent)) 28%, - rgba(104, 140, 190, 0.22) + rgb(var(--steel-mid-rgb) / 0.22) ); background: - radial-gradient(circle at 50% 0%, rgba(255, 255, 255, 0.96), transparent 48%), + radial-gradient(circle at 50% 0%, rgb(var(--surface-rgb) / 0.96), transparent 48%), radial-gradient( circle at 88% 90%, color-mix(in srgb, var(--portal-tint, var(--accent)) 18%, transparent), transparent 42% ), - rgba(255, 255, 255, 0.58); + rgb(var(--surface-rgb) / 0.58); color: var(--ink); text-align: left; - box-shadow: 0 10px 22px rgba(94, 132, 185, 0.08); + box-shadow: 0 10px 22px rgb(var(--steel-mid-rgb) / 0.08); transition: transform 160ms ease, border-color 160ms ease, @@ -278,18 +278,18 @@ border-color: color-mix( in srgb, var(--portal-tint, var(--accent)) 46%, - rgba(255, 255, 255, 0.46) + rgb(var(--edge-rgb) / 0.46) ); background: - radial-gradient(circle at 50% 0%, rgba(255, 255, 255, 0.98), transparent 48%), + radial-gradient(circle at 50% 0%, rgb(var(--surface-rgb) / 0.98), transparent 48%), radial-gradient( circle at 88% 90%, color-mix(in srgb, var(--portal-tint, var(--accent)) 24%, transparent), transparent 42% ), - rgba(255, 255, 255, 0.86); + rgb(var(--surface-rgb) / 0.86); box-shadow: 0 6px 14px - color-mix(in srgb, var(--portal-tint, var(--accent)) 18%, rgba(62, 132, 163, 0.12)); + color-mix(in srgb, var(--portal-tint, var(--accent)) 18%, rgb(var(--shadow-soft-rgb) / 0.12)); transform: translateY(-1px); } @@ -301,8 +301,8 @@ height: 28px; border-radius: 8px; background: - radial-gradient(circle at 35% 22%, #ffffff, transparent 44%), - linear-gradient(145deg, #dff9ff, #ffffff); + radial-gradient(circle at 35% 22%, rgb(var(--surface-rgb)), transparent 44%), + linear-gradient(145deg, rgb(var(--surface-tint-cool-rgb)), rgb(var(--surface-rgb))); color: var(--accent-strong); font-family: var(--font-old); font-size: 14px; @@ -346,7 +346,7 @@ border-radius: 8px; padding: 0; opacity: 0; - background: rgba(255, 255, 255, 0.78); + background: rgb(var(--surface-rgb) / 0.78); color: var(--muted); box-shadow: none; transition: diff --git a/src/renderer/src/components/AirView.tsx b/src/renderer/src/components/AirView.tsx index 4e46c24..4850b7e 100644 --- a/src/renderer/src/components/AirView.tsx +++ b/src/renderer/src/components/AirView.tsx @@ -23,7 +23,7 @@ import { WandSparkles, Wind } from 'lucide-react' -import { formatDate, formatVisibleModelName } from '../utils/aether-ui' +import { countLabel, formatDate, formatVisibleModelName } from '../utils/aether-ui' type AirViewProps = { busy: string | null @@ -221,7 +221,7 @@ export function AirView({
{selectedQuickLens} - {collections.length} hubs indexed + {countLabel(collections.length, 'hub')} indexed {status?.chatModel ? formatVisibleModelName(status.chatModel) @@ -235,7 +235,7 @@ export function AirView({

Render

{prepared - ? `${prepared.sources.length} sources prepared` + ? `${countLabel(prepared.sources.length, 'source')} prepared` : 'Preview context before writing a file.'}

@@ -269,7 +269,7 @@ export function AirView({

Context Preview

{prepared - ? `${prepared.sources.length} citations · ${formatVisibleModelName(prepared.model ?? 'deterministic-scaffold')}` + ? `${countLabel(prepared.sources.length, 'citation')} · ${formatVisibleModelName(prepared.model ?? 'deterministic-scaffold')}` : 'Sources, citations, coverage, and Markdown will appear here.'}

@@ -310,7 +310,7 @@ export function AirView({

Recent Renders

-

{recent.length} local dossiers

+

{countLabel(recent.length, 'local dossier')}

@@ -324,7 +324,7 @@ export function AirView({ {file.title} {file.lens || 'AiR lens'} - {formatDate(file.renderedAt)} · {file.sourceCount} sources + {formatDate(file.renderedAt)} · {countLabel(file.sourceCount, 'source')}
diff --git a/src/renderer/src/components/BrowserChrome.tsx b/src/renderer/src/components/BrowserChrome.tsx index 2db6500..ee992b1 100644 --- a/src/renderer/src/components/BrowserChrome.tsx +++ b/src/renderer/src/components/BrowserChrome.tsx @@ -1,7 +1,7 @@ import { CSSProperties, FormEvent, MouseEvent, useEffect, useState } from 'react' import { BrowserTabSummary, CaptureResult, CollectionSummary } from '../../../shared/aether' import { QuickAction } from '../types/ui' -import { getTabTint } from '../utils/aether-ui' +import { countLabel, getTabTint } from '../utils/aether-ui' import { ChevronLeftIcon, ChevronRightIcon, @@ -254,7 +254,7 @@ export function BrowserChrome({ ))} {tabs.length >= 12 && ( - + {tabs.length} )} @@ -326,7 +326,7 @@ export function BrowserChrome({ > { if (event.target.value === CREATE_COLLECTION_VALUE) { diff --git a/src/renderer/src/tauri-aether.ts b/src/renderer/src/tauri-aether.ts index 8a9e659..1a7bf22 100644 --- a/src/renderer/src/tauri-aether.ts +++ b/src/renderer/src/tauri-aether.ts @@ -39,7 +39,11 @@ import { SemanticTrailResult, StatusToastInput, SystemStatus, - UpdateCheckResult + DiagnosticEntry, + DiagnosticsExportResult, + UpdateCheckResult, + UpdateInstallProgress, + UpdateInstallResult } from '../../shared/aether' import { IS_ANDROID } from './utils/platform' @@ -136,7 +140,12 @@ if (isTauri) { updateSettings: (input) => call('aether_system_update_settings', { input }), updateModels: (input) => call('aether_system_update_models', { input }), checkForUpdate: () => call('aether_system_check_for_update'), + installUpdate: () => call('aether_system_install_update'), + relaunch: () => call('aether_system_relaunch'), exportLibrary: () => call('aether_system_export_library'), + diagnostics: () => call('aether_system_diagnostics'), + exportDiagnostics: () => + call('aether_system_export_diagnostics'), indexStatus: () => call('aether_library_index_status'), reindexLibrary: () => call('aether_library_reindex'), openExternalUrl: (url) => call('aether_system_open_external_url', { url }), @@ -184,6 +193,15 @@ if (isTauri) { void unlisten.then((dispose) => dispose()) } }, + onUpdateProgress: (listener: (progress: UpdateInstallProgress) => void) => { + const unlisten = listen('aether:update-progress', (event) => + listener(event.payload) + ) + + return () => { + void unlisten.then((dispose) => dispose()) + } + }, onChatStream: (listener: (event: ChatStreamEvent) => void) => { const unlisten = listen('aether:chat-stream', (event) => listener(event.payload) diff --git a/src/renderer/src/utils/aether-ui.ts b/src/renderer/src/utils/aether-ui.ts index d53a94e..0b87e46 100644 --- a/src/renderer/src/utils/aether-ui.ts +++ b/src/renderer/src/utils/aether-ui.ts @@ -1,4 +1,9 @@ -import { BrowserTabSummary, IcebergItem, SavedIcebergSummary } from '../../../shared/aether' +import { + BrowserTabSummary, + IcebergItem, + SavedIcebergSummary, + UpdateInstallProgress +} from '../../../shared/aether' import { QuickAction } from '../types/ui' export function getCaptureHost(url: string): string { @@ -9,6 +14,37 @@ export function getCaptureHost(url: string): string { } } +// "1 source" / "2 sources". English-only, matching the rest of the UI — if ÆTHER is +// ever localized this becomes Intl.PluralRules, but a bespoke rule per call site is +// what produced "1 local citations" in the first place. +export function plural(count: number, singular: string, pluralForm?: string): string { + return count === 1 ? singular : (pluralForm ?? `${singular}s`) +} + +// The common case: a count and its noun together. +export function countLabel(count: number, singular: string, pluralForm?: string): string { + return `${count} ${plural(count, singular, pluralForm)}` +} + +// Decimal units, matching what an OS file listing and a release page both report, +// so an export or download size here can be compared with what the user sees there. +export function formatByteSize(bytes: number): string { + if (bytes >= 1_000_000_000) return `${(bytes / 1_000_000_000).toFixed(1)} GB` + if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)} MB` + if (bytes >= 1_000) return `${Math.round(bytes / 1_000)} KB` + return `${bytes} B` +} + +// The updater reports a total only when the release host sends a Content-Length, +// so the "of N" half has to be optional rather than showing a fabricated total or +// a progress bar stuck at 0%. +export function formatUpdateProgress(progress: UpdateInstallProgress | null): string { + if (!progress) return 'Contacting the update server' + const downloaded = formatByteSize(progress.downloadedBytes) + if (!progress.totalBytes) return `Downloaded ${downloaded}` + return `${downloaded} of ${formatByteSize(progress.totalBytes)}` +} + export function cleanTitle(title: string): string { if (!title) return '' diff --git a/src/shared/aether.ts b/src/shared/aether.ts index f3bab16..cbed2f4 100644 --- a/src/shared/aether.ts +++ b/src/shared/aether.ts @@ -46,10 +46,15 @@ export interface UpdateSettings { lastCheckedAt?: string } +// 'system' follows prefers-color-scheme; 'light' and 'dark' override it in both +// directions, so a light theme stays available on a dark desktop. +export type Appearance = 'system' | 'light' | 'dark' + export interface AppSettings { browser: BrowserSettings developerMode: boolean updates: UpdateSettings + appearance: Appearance } export interface CollectionSummary { @@ -335,7 +340,6 @@ export interface IcebergItem { jargonDensity?: number prerequisiteDepth?: number obscurity?: number - confidence?: number reason?: string } @@ -426,6 +430,41 @@ export interface UpdateCheckResult { error?: string } +// `installed` is the only success. The other three are distinct reasons the app +// cannot update itself, and the UI has to tell them apart: `unconfigured` means +// this build has no signing key, `unsupported` means the install method is owned +// by a package manager or an app store, `unavailable` means the signed manifest +// has nothing newer for this platform even though a release exists. +export type UpdateInstallStatus = 'installed' | 'unconfigured' | 'unsupported' | 'unavailable' + +export interface UpdateInstallResult { + status: UpdateInstallStatus + version?: string + needsRestart: boolean + message: string +} + +export interface UpdateInstallProgress { + downloadedBytes: number + totalBytes?: number + done: boolean +} + +export type DiagnosticLevel = 'info' | 'warn' | 'error' + +export interface DiagnosticEntry { + at: string + level: DiagnosticLevel + message: string +} + +export interface DiagnosticsExportResult { + path: string + filename: string + byteSize: number + exportedAt: string +} + export type ModelDownloadChoice = 'lite' | 'wise' export type ModelDownloadStatus = 'queued' | 'downloading' | 'skipped' | 'complete' | 'error' @@ -578,8 +617,21 @@ export interface AetherApi { updateSettings(input: Partial): Promise updateModels(input: { embeddingModel?: string; chatModel?: string }): Promise checkForUpdate(): Promise + // Downloads, signature-verifies, and installs the newest signed release. + // Reads the updater manifest, not the GitHub API that checkForUpdate uses, so + // the two can legitimately disagree — see UpdateInstallStatus. + installUpdate(): Promise + // Quits and relaunches. Only meaningful after installUpdate reported + // needsRestart, and never returns when it succeeds. + relaunch(): Promise // Snapshots every local store into a timestamped folder and reveals it. exportLibrary(): Promise + // Recent operational log entries, newest first. Local only — see + // src-tauri/src/diagnostics.rs for what is deliberately never recorded. + diagnostics(): Promise + // Copies the log somewhere attachable and reveals it. The only way anything + // here leaves the machine, and only when the user asks. + exportDiagnostics(): Promise // Loads the vector store, so Settings asks for this on open rather than at startup. indexStatus(): Promise // Re-embeds retained chunk text with the loaded model. The only way to recover @@ -605,6 +657,7 @@ export interface AetherApi { onState(listener: (state: AetherState) => void): () => void onCaptureProgress(listener: (progress: CaptureProgress) => void): () => void onModelDownloadProgress(listener: (progress: ModelDownloadProgress) => void): () => void + onUpdateProgress(listener: (progress: UpdateInstallProgress) => void): () => void onChatStream(listener: (event: ChatStreamEvent) => void): () => void onDownload(listener: (progress: DownloadProgress) => void): () => void onShortcut(listener: (shortcut: AetherShortcutId) => void): () => void From fcaeecc7f3765616ce0fa2bb4383c21c22377541 Mon Sep 17 00:00:00 2001 From: Can Ur Date: Sat, 25 Jul 2026 18:06:55 +0200 Subject: [PATCH 11/15] build: add updater public key --- src-tauri/tauri.conf.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8b1a15a..c39bbf5 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -62,7 +62,7 @@ "endpoints": [ "https://github.com/CanPixel/aether/releases/latest/download/latest.json" ], - "pubkey": "", + "pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDQ5QzUwQTk2MkJFQjA4MEIKUldRTENPc3JsZ3JGU1FWamQrMkUrb0hmSDR2MktHZjltZVJyb0RCcHQzNFdEelIxNlYzdmlvOFgK", "windows": { "installMode": "passive" } From 34a849201769a5c6fb3b37688e446f5d756b5201 Mon Sep 17 00:00:00 2001 From: Can Ur Date: Sat, 25 Jul 2026 18:38:25 +0200 Subject: [PATCH 12/15] dark mode fixes --- src-tauri/src/lib.rs | 108 ++++++++++++++++++ .../src/assets/styles/crystallizer.css | 4 +- src/renderer/src/assets/styles/foundation.css | 98 +++++++++++++++- .../src/assets/styles/setup-settings.css | 4 +- .../src/assets/styles/shared-overrides.css | 49 ++++++-- src/renderer/src/components/BrowserChrome.tsx | 4 +- src/renderer/src/components/Crystallizer.tsx | 2 +- src/renderer/src/components/Dashboard.tsx | 2 +- src/renderer/src/components/FlowView.tsx | 8 +- .../src/components/IntelligencePanel.tsx | 12 +- src/renderer/src/components/MobileShell.tsx | 6 +- 11 files changed, 264 insertions(+), 33 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8650bd0..f43ceb9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1168,6 +1168,114 @@ mod tests { ); } + // The AiON panel's primary button shipped broken in dark mode because one opaque + // gradient mixed a themed surface channel (which correctly inverts to navy) with + // unthemed accent channels (which stay pale). The result ran from near-black to + // pale blue under light label text. Low-alpha decorative gradients mix the two + // families harmlessly, so this only guards fills solid enough to sit under text. + #[test] + fn opaque_fills_do_not_mix_themed_and_unthemed_channels() { + const STYLES: &str = "../src/renderer/src/assets/styles"; + let foundation = + std::fs::read_to_string(format!("{STYLES}/foundation.css")).expect("foundation.css"); + + let channels_in = |block: &str| -> std::collections::HashSet { + block + .lines() + .filter_map(|line| { + let line = line.trim(); + let name = line.strip_prefix("--")?.split_once(':')?.0; + name.ends_with("-rgb").then(|| name.to_string()) + }) + .collect() + }; + + let root_end = foundation.find("\n}").expect("closing brace for :root"); + let (root, rest) = foundation.split_at(root_end); + let dark_start = rest + .find(":root[data-theme='dark']") + .expect("explicit dark block"); + let defined = channels_in(root); + let themed = channels_in(&rest[dark_start..]); + + // Only surfaces and ink decide light-versus-dark; a brand accent that keeps + // its hue on both themes is intentional. + let must_flip = |name: &str| { + name.starts_with("surface") || name.starts_with("ink") || name == "highlight-rgb" + }; + + let mut offenders = Vec::new(); + for entry in std::fs::read_dir(STYLES).expect("styles dir") { + let path = entry.expect("dir entry").path(); + if path.extension().is_none_or(|ext| ext != "css") { + continue; + } + let css = std::fs::read_to_string(&path).expect("stylesheet"); + for declaration in css.split(';') { + let Some((prop, value)) = declaration.split_once(':') else { + continue; + }; + if prop.trim_start_matches(['{', '}', '\n', ' ']).trim() != "background" { + continue; + } + // color-mix() composites by percentage, not by the `/ alpha` this + // check reads, so its channels would all look opaque. Those call + // sites are checked by eye instead. + if value.contains("color-mix") { + continue; + } + + let mut flips = Vec::new(); + let mut fixed = Vec::new(); + for (index, _) in value.match_indices("rgb(var(--") { + let tail = &value[index + "rgb(var(--".len()..]; + let name: String = tail + .chars() + .take_while(|c| c.is_ascii_alphanumeric() || *c == '-') + .collect(); + if !defined.contains(&name) { + continue; + } + // Alpha follows the name as `) / 0.42)`; absent means fully opaque. + // The name has to be stepped over first, or the `/` search finds + // the one in a later stop and every fill reads as opaque. + let after_name = &tail[name.len()..]; + let alpha = after_name + .strip_prefix(')') + .map(str::trim_start) + .and_then(|rest| rest.strip_prefix('/')) + .and_then(|rest| rest.trim_start().split(')').next()) + .and_then(|rest| rest.trim().parse::().ok()) + .unwrap_or(1.0); + if alpha < 0.9 { + continue; + } + if must_flip(&name) { + flips.push(name); + } else if !themed.contains(&name) { + fixed.push(name); + } + } + + if !flips.is_empty() && !fixed.is_empty() { + offenders.push(format!( + "{}: {} (inverts) mixed with {} (does not)", + path.file_name().unwrap_or_default().to_string_lossy(), + flips.join(", "), + fixed.join(", ") + )); + } + } + } + + assert!( + offenders.is_empty(), + "opaque fills mixing themed and unthemed channels render incoherently in \ + dark mode:\n {}", + offenders.join("\n ") + ); + } + // The shipped config carries an empty pubkey placeholder, and that has to read // as "not configured" rather than as a key — otherwise the app offers an // Install button that downloads a hundred megabytes and then fails signature diff --git a/src/renderer/src/assets/styles/crystallizer.css b/src/renderer/src/assets/styles/crystallizer.css index dd6a1cc..e5efe4c 100644 --- a/src/renderer/src/assets/styles/crystallizer.css +++ b/src/renderer/src/assets/styles/crystallizer.css @@ -242,7 +242,7 @@ white-space: nowrap; color: rgb(var(--highlight-rgb)); - background: linear-gradient(135deg, rgb(var(--ink-abyss-rgb)), rgb(var(--marine-rgb)) 58%, rgb(var(--teal-deep-rgb))); + background: linear-gradient(135deg, rgb(var(--crystal-deep-rgb)), rgb(var(--marine-rgb)) 58%, rgb(var(--teal-deep-rgb))); box-shadow: 0 16px 34px rgb(var(--depth-rgb) / 0.24), 0 1px 0 rgb(var(--highlight-rgb) / 0.28) inset; @@ -257,7 +257,7 @@ } .crystallizer-search button:hover { - background: linear-gradient(135deg, rgb(var(--ink-abyss-rgb)), rgb(var(--marine-rgb)) 42%, rgb(var(--teal-deep-rgb))); + background: linear-gradient(135deg, rgb(var(--crystal-deep-rgb)), rgb(var(--marine-rgb)) 42%, rgb(var(--teal-deep-rgb))); box-shadow: 0 20px 42px rgb(var(--depth-rgb) / 0.28), 0 1px 0 rgb(var(--highlight-rgb) / 0.38) inset; diff --git a/src/renderer/src/assets/styles/foundation.css b/src/renderer/src/assets/styles/foundation.css index f40bc30..dc96071 100644 --- a/src/renderer/src/assets/styles/foundation.css +++ b/src/renderer/src/assets/styles/foundation.css @@ -182,6 +182,22 @@ --font-old: 'New York', 'Iowan Old Style', 'Palatino Linotype', Georgia, serif; --font-alt: Didot, 'Bodoni 72', 'Bodoni 72 Smallcaps', 'Baskerville', 'Iowan Old Style', Georgia, serif; + /* Primary-action button gradient. These exist because the button previously mixed + --surface-sky (a themed surface, which correctly inverts to navy in dark mode) + with --accent-light/--azure (unthemed, which stay pale). The result in dark mode + was a gradient running from near-black to pale blue with unreadable label text. + Light values below are byte-identical to the literals they replaced. */ + --primary-from-rgb: 224 242 254; + --primary-from-hover-rgb: 229 248 255; + --primary-mid-rgb: 106 193 229; + --primary-to-rgb: 54 122 178; + --primary-edge-rgb: 54 122 178; + /* The dark end of the iCE Crystallize button. It previously used --ink-abyss, + which is an *ink* channel and therefore inverts to near-white in dark mode — + under light label text that made the button unreadable. This stays dark in + both themes because the button is dark by design, not by theme. */ + --crystal-deep-rgb: 15 23 42; + } /* --------------------------------------------------------------------------- @@ -265,6 +281,14 @@ --wordmark-ink-rgb: 214 233 250; --wordmark-ink-soft-rgb: 150 196 226; + /* Deep enough that --highlight (198 220 246) clears 4.5:1 on every stop: + from 8.40:1, mid 4.96:1, to 7.07:1. */ + --primary-from-rgb: 16 58 92; + --primary-from-hover-rgb: 22 72 112; + --primary-mid-rgb: 28 94 138; + --primary-to-rgb: 18 70 108; + --primary-edge-rgb: 96 168 208; + --crystal-deep-rgb: 10 17 30; --text-success-rgb: 118 214 178; --success-rgb: 96 200 162; --aurora-deep-rgb: 92 198 166; @@ -354,6 +378,14 @@ --wordmark-ink-rgb: 214 233 250; --wordmark-ink-soft-rgb: 150 196 226; + /* Deep enough that --highlight (198 220 246) clears 4.5:1 on every stop: + from 8.40:1, mid 4.96:1, to 7.07:1. */ + --primary-from-rgb: 16 58 92; + --primary-from-hover-rgb: 22 72 112; + --primary-mid-rgb: 28 94 138; + --primary-to-rgb: 18 70 108; + --primary-edge-rgb: 96 168 208; + --crystal-deep-rgb: 10 17 30; --text-success-rgb: 118 214 178; --success-rgb: 96 200 162; --aurora-deep-rgb: 92 198 166; @@ -484,6 +516,28 @@ textarea { button { cursor: pointer; + /* Transitioned here rather than per-component so every control reacts, including + the many buttons that carry no class of their own. */ + transition: + transform 120ms ease, + border-color 160ms ease, + background 160ms ease, + box-shadow 160ms ease, + opacity 160ms ease; +} + +/* Baseline press feedback for every button. Component rules that define their own + :active still win; this only covers the ones that previously did nothing at all. */ +button:not(:disabled):active { + transform: translateY(1px); +} + +/* Keyboard focus. There was exactly one focus-visible rule for buttons in the whole + stylesheet, so tabbing through the app was almost entirely invisible. */ +button:focus-visible { + outline: 2px solid rgb(var(--accent-strong-rgb)); + outline-offset: 2px; + border-radius: 10px; } button:disabled { @@ -492,6 +546,16 @@ button:disabled { filter: grayscale(0.55) saturate(0.68); } +@media (prefers-reduced-motion: reduce) { + button { + transition: none; + } + + button:not(:disabled):active { + transform: none; + } +} + .aether-shell { position: relative; display: grid; @@ -1023,10 +1087,15 @@ button { .semantic-trail-form button, .new-collection-button, .save-page-button { - border-color: rgb(var(--azure-rgb) / 0.34); + border-color: rgb(var(--primary-edge-rgb) / 0.34); background: linear-gradient(180deg, rgb(var(--surface-rgb) / 0.24), transparent 44%), - linear-gradient(145deg, rgb(var(--surface-sky-rgb)), rgb(var(--accent-light-rgb)) 52%, rgb(var(--azure-rgb))); + linear-gradient( + 145deg, + rgb(var(--primary-from-rgb)), + rgb(var(--primary-mid-rgb)) 52%, + rgb(var(--primary-to-rgb)) + ); color: rgb(var(--highlight-rgb)); box-shadow: inset 0 1px 0 rgb(var(--highlight-rgb) / 0.5), @@ -1044,14 +1113,33 @@ button { .semantic-trail-form button:hover, .new-collection-button:hover, .save-page-button:hover { - border-color: rgb(var(--azure-rgb) / 0.42); + border-color: rgb(var(--primary-edge-rgb) / 0.42); background: linear-gradient(180deg, rgb(var(--surface-rgb) / 0.32), transparent 46%), - linear-gradient(145deg, rgb(var(--surface-tint-cool-rgb)), rgb(var(--accent-light-rgb)) 52%, rgb(var(--azure-rgb))); + linear-gradient( + 145deg, + rgb(var(--primary-from-hover-rgb)), + rgb(var(--primary-mid-rgb)) 52%, + rgb(var(--primary-to-rgb)) + ); box-shadow: inset 0 1px 0 rgb(var(--highlight-rgb) / 0.58), inset 0 -1px 0 rgb(var(--ocean-deep-rgb) / 0.16), - 0 11px 22px rgb(var(--azure-rgb) / 0.18); + 0 11px 22px rgb(var(--primary-edge-rgb) / 0.18); +} + +/* Press feedback. Only 5 :active rules existed across 11.7k lines of CSS, so most + controls felt inert on click regardless of theme. */ +.address-bar button:active, +.start-search button:active, +.chat-form button:active, +.semantic-trail-form button:active, +.new-collection-button:active, +.save-page-button:active { + transform: translateY(1px); + box-shadow: + inset 0 1px 3px rgb(var(--depth-rgb) / 0.28), + 0 3px 8px rgb(var(--primary-edge-rgb) / 0.16); } .save-page-button { diff --git a/src/renderer/src/assets/styles/setup-settings.css b/src/renderer/src/assets/styles/setup-settings.css index 569725d..5d4a561 100644 --- a/src/renderer/src/assets/styles/setup-settings.css +++ b/src/renderer/src/assets/styles/setup-settings.css @@ -862,9 +862,9 @@ border-radius: 999px; background: linear-gradient( 180deg, - rgb(var(--surface-rgb) / 0.98), + rgb(var(--accent-soft-rgb) / 0.98), rgb(var(--accent-pale-rgb) / 0.92) 44%, - rgba(54, 150, 206, 0.82) + rgb(var(--accent-ocean-rgb) / 0.82) ); background-clip: padding-box; box-shadow: diff --git a/src/renderer/src/assets/styles/shared-overrides.css b/src/renderer/src/assets/styles/shared-overrides.css index 2e5f0aa..f370bd5 100644 --- a/src/renderer/src/assets/styles/shared-overrides.css +++ b/src/renderer/src/assets/styles/shared-overrides.css @@ -1,6 +1,38 @@ +/* The only call to action on an otherwise empty surface, so it gets the primary + treatment. It was previously 30px with the shadow stripped, which left the app's + most important first-run action looking like disabled text. */ .empty-state button { - height: 30px; - box-shadow: none; + height: 36px; + padding: 0 18px; + border-color: rgb(var(--primary-edge-rgb) / 0.34); + background: + linear-gradient(180deg, rgb(var(--surface-rgb) / 0.24), transparent 44%), + linear-gradient( + 145deg, + rgb(var(--primary-from-rgb)), + rgb(var(--primary-mid-rgb)) 52%, + rgb(var(--primary-to-rgb)) + ); + color: rgb(var(--highlight-rgb)); + box-shadow: + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.5), + 0 9px 18px rgb(var(--primary-edge-rgb) / 0.14); + font-family: var(--font-ice); + font-weight: 600; + font-size: 14px; +} + +.empty-state button:hover { + border-color: rgb(var(--primary-edge-rgb) / 0.5); + box-shadow: + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.58), + 0 11px 22px rgb(var(--primary-edge-rgb) / 0.2); +} + +.empty-state button:active { + box-shadow: + inset 0 1px 3px rgb(var(--depth-rgb) / 0.28), + 0 3px 8px rgb(var(--primary-edge-rgb) / 0.16); } .empty-state { @@ -415,16 +447,19 @@ font-weight: 820; } +/* Same gradient family as the primary buttons in foundation.css, and it carried the + same defect: a themed surface stop mixed with unthemed accents, which in dark mode + ran from navy to pale blue under light label text. */ .primary-button, .danger-primary { - border-color: rgb(var(--sky-deep-rgb) / 0.4); + border-color: rgb(var(--primary-edge-rgb) / 0.4); background: - radial-gradient(circle at 28% 16%, rgb(var(--surface-rgb) / 0.95), transparent 36%), + radial-gradient(circle at 28% 16%, rgb(var(--highlight-rgb) / 0.18), transparent 36%), linear-gradient( 145deg, - rgb(var(--surface-tint-cool-rgb) / 0.96), - rgb(var(--accent-light-rgb) / 0.94) 48%, - rgb(var(--azure-rgb) / 0.92) + rgb(var(--primary-from-rgb) / 0.96), + rgb(var(--primary-mid-rgb) / 0.94) 48%, + rgb(var(--primary-to-rgb) / 0.92) ); color: rgb(var(--highlight-rgb)); box-shadow: diff --git a/src/renderer/src/components/BrowserChrome.tsx b/src/renderer/src/components/BrowserChrome.tsx index ee992b1..1535beb 100644 --- a/src/renderer/src/components/BrowserChrome.tsx +++ b/src/renderer/src/components/BrowserChrome.tsx @@ -285,10 +285,10 @@ export function BrowserChrome({ disabled={tabs.length <= 1} onClick={() => runTabMenuAction(() => onCloseOtherTabs(menuTab.id))} > - Close others + Close Others
)} diff --git a/src/renderer/src/components/Crystallizer.tsx b/src/renderer/src/components/Crystallizer.tsx index 00fc4a8..3247ded 100644 --- a/src/renderer/src/components/Crystallizer.tsx +++ b/src/renderer/src/components/Crystallizer.tsx @@ -1027,7 +1027,7 @@ export function Crystallizer({ type="button" > - Open in library + Open in Library )}
) : ( diff --git a/src/renderer/src/components/FlowView.tsx b/src/renderer/src/components/FlowView.tsx index 9de4a82..2e48c3c 100644 --- a/src/renderer/src/components/FlowView.tsx +++ b/src/renderer/src/components/FlowView.tsx @@ -391,7 +391,7 @@ export function FlowView({ {collections.length > 0 && ( )}
@@ -581,7 +581,7 @@ function FlowNodeDetail({ {node.kind === 'source' && ( )} {node.kind === 'hub' && node.collectionId && ( @@ -591,13 +591,13 @@ function FlowNodeDetail({ type="button" > - Open hub + Open Hub )} {node.kind !== 'query' && ( )} diff --git a/src/renderer/src/components/IntelligencePanel.tsx b/src/renderer/src/components/IntelligencePanel.tsx index 97c8aca..b6c478a 100644 --- a/src/renderer/src/components/IntelligencePanel.tsx +++ b/src/renderer/src/components/IntelligencePanel.tsx @@ -521,14 +521,14 @@ export function IntelligencePanel({ data-tooltip={showTooltips ? 'Model Settings' : undefined} data-tooltip-side={showTooltips ? 'left' : undefined} onClick={() => setSettingsOpen((current) => !current)} - title="Model settings" + title="Model Settings" type="button" > {status?.chatModel ? formatVisibleModelName(status.chatModel, { developerMode, role: 'chat' }) - : 'Model settings'} + : 'Model Settings'} ) : chatModelOptions.length === 0 ? ( @@ -544,7 +544,7 @@ export function IntelligencePanel({ type="button" >
- {updateCheck?.releaseNotes && ( -

{updateCheck.releaseNotes}

+ {releaseNotes.summary && ( +

{releaseNotes.summary}

+ )} + + {releaseNotes.changelogUrl && ( + + + + Full Changelog + {releaseNotes.changelogLabel} + + )} {updateInstalling && ( diff --git a/src/renderer/src/assets/styles/crystallizer.css b/src/renderer/src/assets/styles/crystallizer.css index e5efe4c..43499b0 100644 --- a/src/renderer/src/assets/styles/crystallizer.css +++ b/src/renderer/src/assets/styles/crystallizer.css @@ -92,7 +92,12 @@ width: 1px; height: 30px; margin-left: 3px; - background: linear-gradient(to bottom, transparent, rgb(var(--accent-vivid-rgb) / 0.45), transparent); + background: linear-gradient( + to bottom, + transparent, + rgb(var(--accent-vivid-rgb) / 0.45), + transparent + ); } .brand-copy { @@ -158,7 +163,7 @@ } .crystallizer-brand-subtitle { - color: rgb(var(--text-accent-rgb)); + color: rgb(var(--text-ocean-rgb)); font-size: 9px; font-weight: 900; line-height: 1; @@ -242,7 +247,12 @@ white-space: nowrap; color: rgb(var(--highlight-rgb)); - background: linear-gradient(135deg, rgb(var(--crystal-deep-rgb)), rgb(var(--marine-rgb)) 58%, rgb(var(--teal-deep-rgb))); + background: linear-gradient( + 135deg, + rgb(var(--crystal-deep-rgb)), + rgb(var(--marine-rgb)) 58%, + rgb(var(--teal-deep-rgb)) + ); box-shadow: 0 16px 34px rgb(var(--depth-rgb) / 0.24), 0 1px 0 rgb(var(--highlight-rgb) / 0.28) inset; @@ -257,7 +267,12 @@ } .crystallizer-search button:hover { - background: linear-gradient(135deg, rgb(var(--crystal-deep-rgb)), rgb(var(--marine-rgb)) 42%, rgb(var(--teal-deep-rgb))); + background: linear-gradient( + 135deg, + rgb(var(--crystal-deep-rgb)), + rgb(var(--marine-rgb)) 42%, + rgb(var(--teal-deep-rgb)) + ); box-shadow: 0 20px 42px rgb(var(--depth-rgb) / 0.28), 0 1px 0 rgb(var(--highlight-rgb) / 0.38) inset; @@ -279,7 +294,11 @@ .crystallizer-search .crystallizer-save-button:hover { border-color: rgb(var(--accent-deep-rgb) / 0.46); background: - linear-gradient(180deg, rgb(var(--surface-rgb) / 0.86), rgb(var(--surface-tint-cool-rgb) / 0.68)), + linear-gradient( + 180deg, + rgb(var(--surface-rgb) / 0.86), + rgb(var(--surface-tint-cool-rgb) / 0.68) + ), rgb(var(--surface-rgb) / 0.82); box-shadow: 0 16px 34px rgb(var(--depth-rgb) / 0.12), @@ -608,7 +627,11 @@ padding: 9px 10px 9px 8px; background: radial-gradient(circle at 18% 10%, rgb(var(--surface-rgb) / 0.96), transparent 32%), - linear-gradient(180deg, rgb(var(--surface-rgb) / 0.9), rgb(var(--surface-tint-warm-rgb) / 0.72)), + linear-gradient( + 180deg, + rgb(var(--surface-rgb) / 0.9), + rgb(var(--surface-tint-warm-rgb) / 0.72) + ), color-mix(in srgb, var(--layer-accent) 5%, white); color: rgb(var(--night-rgb)); text-align: left; @@ -632,7 +655,11 @@ border-left-color: var(--layer-accent); background: radial-gradient(circle at 18% 10%, rgb(var(--surface-rgb) / 1), transparent 34%), - linear-gradient(180deg, rgb(var(--surface-rgb) / 0.98), rgb(var(--surface-tint-warm-rgb) / 0.82)), + linear-gradient( + 180deg, + rgb(var(--surface-rgb) / 0.98), + rgb(var(--surface-tint-warm-rgb) / 0.82) + ), color-mix(in srgb, var(--layer-accent) 9%, white); box-shadow: 0 18px 36px rgb(var(--depth-rgb) / 0.16), @@ -700,7 +727,11 @@ padding: 11px 13px 13px 10px; background: radial-gradient(circle at 18% 10%, rgb(var(--surface-rgb) / 1), transparent 34%), - linear-gradient(180deg, rgb(var(--surface-rgb) / 0.99), rgb(var(--surface-tint-warm-rgb) / 0.86)), + linear-gradient( + 180deg, + rgb(var(--surface-rgb) / 0.99), + rgb(var(--surface-tint-warm-rgb) / 0.86) + ), color-mix(in srgb, var(--layer-accent) 10%, white); color: rgb(var(--night-rgb)); text-align: left; @@ -797,7 +828,11 @@ content: ''; background: radial-gradient(circle at 50% 40%, rgb(var(--surface-rgb) / 0.34), transparent 34%), - linear-gradient(180deg, rgb(var(--surface-tint-rgb) / 0.08), rgb(var(--surface-tint-cool-rgb) / 0.18)); + linear-gradient( + 180deg, + rgb(var(--surface-tint-rgb) / 0.08), + rgb(var(--surface-tint-cool-rgb) / 0.18) + ); backdrop-filter: blur(10px) saturate(1.12); -webkit-backdrop-filter: blur(10px) saturate(1.12); mask-image: radial-gradient( @@ -882,7 +917,11 @@ background: radial-gradient(circle at 50% 26%, rgb(var(--surface-rgb) / 0.98), transparent 36%), radial-gradient(circle at 32% 78%, rgb(var(--sky-rgb) / 0.22), transparent 42%), - linear-gradient(145deg, rgb(var(--surface-rgb) / 0.76), rgb(var(--surface-tint-cool-rgb) / 0.48)); + linear-gradient( + 145deg, + rgb(var(--surface-rgb) / 0.76), + rgb(var(--surface-tint-cool-rgb) / 0.48) + ); box-shadow: inset 0 1px 0 rgb(var(--highlight-rgb) / 0.92), 0 32px 82px rgb(var(--depth-rgb) / 0.2), @@ -998,7 +1037,11 @@ rgb(var(--sky-pale-rgb) / 0.44) 48%, rgb(var(--accent-vivid-rgb) / 0.1) 72% ), - linear-gradient(180deg, rgb(var(--surface-rgb) / 0.74), rgb(var(--surface-tint-cool-rgb) / 0.42)); + linear-gradient( + 180deg, + rgb(var(--surface-rgb) / 0.74), + rgb(var(--surface-tint-cool-rgb) / 0.42) + ); box-shadow: inset 0 1px 0 rgb(var(--highlight-rgb) / 0.92), 0 0 26px rgb(var(--sky-vivid-rgb) / 0.38), @@ -1138,7 +1181,11 @@ .layer-strip.compact button.active { border-color: color-mix(in srgb, var(--layer-accent, rgb(var(--accent-deep-rgb))) 46%, white); background: color-mix(in srgb, var(--layer-accent, rgb(var(--accent-deep-rgb))) 10%, white); - color: color-mix(in srgb, var(--layer-accent, rgb(var(--text-accent-rgb))) 84%, rgb(var(--night-rgb))); + color: color-mix( + in srgb, + var(--layer-accent, rgb(var(--text-accent-rgb))) 84%, + rgb(var(--night-rgb)) + ); } .layer-strip.compact button:hover { @@ -1171,7 +1218,11 @@ width: 19px; height: 19px; border-radius: 6px; - background: color-mix(in srgb, var(--layer-accent, rgb(var(--accent-deep-rgb))) 82%, rgb(var(--surface-rgb))); + background: color-mix( + in srgb, + var(--layer-accent, rgb(var(--accent-deep-rgb))) 82%, + rgb(var(--surface-rgb)) + ); color: rgb(var(--highlight-rgb)); } @@ -1249,7 +1300,11 @@ .canvas-layer-hud.layer-strip.compact button:hover { background: - linear-gradient(135deg, rgb(var(--surface-rgb) / 0.9), rgb(var(--surface-tint-cool-rgb) / 0.72)), + linear-gradient( + 135deg, + rgb(var(--surface-rgb) / 0.9), + rgb(var(--surface-tint-cool-rgb) / 0.72) + ), color-mix(in srgb, var(--layer-accent, rgb(var(--accent-deep-rgb))) 7%, white); box-shadow: 0 8px 18px rgb(var(--depth-rgb) / 0.1), @@ -1258,7 +1313,11 @@ .canvas-layer-hud.layer-strip.compact button.active { background: - linear-gradient(135deg, rgb(var(--surface-rgb) / 0.86), rgb(var(--surface-tint-cool-rgb) / 0.68)), + linear-gradient( + 135deg, + rgb(var(--surface-rgb) / 0.86), + rgb(var(--surface-tint-cool-rgb) / 0.68) + ), color-mix(in srgb, var(--layer-accent, rgb(var(--accent-deep-rgb))) 12%, white); } @@ -1404,7 +1463,13 @@ border-color: rgb(var(--accent-vivid-rgb) / 0.58); background: radial-gradient(circle at 80% 12%, rgb(var(--surface-rgb) / 0.98), transparent 24%), - linear-gradient(135deg, rgb(var(--surface-tint-rgb)) 0%, rgb(var(--sky-pale-rgb)) 18%, rgb(var(--surface-sky-rgb)) 36%, rgb(var(--surface-rgb)) 100%); + linear-gradient( + 135deg, + rgb(var(--surface-tint-rgb)) 0%, + rgb(var(--sky-pale-rgb)) 18%, + rgb(var(--surface-sky-rgb)) 36%, + rgb(var(--surface-rgb)) 100% + ); color: rgb(var(--ink-deep-rgb)); box-shadow: 0 0 0 4px rgb(var(--sky-rgb) / 0.16), @@ -1423,7 +1488,13 @@ border-color: rgb(var(--accent-vivid-rgb) / 0.58); background: radial-gradient(circle at 80% 12%, rgb(var(--surface-rgb) / 0.98), transparent 24%), - linear-gradient(135deg, rgb(var(--surface-tint-rgb)) 0%, rgb(var(--sky-pale-rgb)) 18%, rgb(var(--surface-sky-rgb)) 36%, rgb(var(--surface-rgb)) 100%); + linear-gradient( + 135deg, + rgb(var(--surface-tint-rgb)) 0%, + rgb(var(--sky-pale-rgb)) 18%, + rgb(var(--surface-sky-rgb)) 36%, + rgb(var(--surface-rgb)) 100% + ); color: rgb(var(--ink-deep-rgb)); box-shadow: 0 0 0 4px rgb(var(--sky-rgb) / 0.16), @@ -1451,7 +1522,11 @@ border-color: rgb(var(--sky-rgb) / 0.34); background: radial-gradient(circle at 18% 18%, rgb(var(--surface-rgb) / 0.92), transparent 28%), - linear-gradient(135deg, rgb(var(--surface-rgb) / 0.78), rgb(var(--surface-tint-cool-rgb) / 0.56)); + linear-gradient( + 135deg, + rgb(var(--surface-rgb) / 0.78), + rgb(var(--surface-tint-cool-rgb) / 0.56) + ); color: rgb(var(--marine-rgb)); isolation: isolate; transition: @@ -1477,7 +1552,13 @@ rgb(var(--surface-rgb) / 0.9) 58%, transparent 72% ), - linear-gradient(135deg, rgb(var(--surface-tint-rgb)) 0%, rgb(var(--sky-pale-rgb)) 42%, rgb(var(--surface-sky-rgb)) 68%, rgb(var(--surface-rgb)) 100%); + linear-gradient( + 135deg, + rgb(var(--surface-tint-rgb)) 0%, + rgb(var(--sky-pale-rgb)) 42%, + rgb(var(--surface-sky-rgb)) 68%, + rgb(var(--surface-rgb)) 100% + ); opacity: 0; transform: translateX(-72%) skewX(-12deg); transition: @@ -1489,7 +1570,13 @@ border-color: rgb(var(--accent-vivid-rgb) / 0.58); background: radial-gradient(circle at 80% 12%, rgb(var(--surface-rgb) / 0.98), transparent 24%), - linear-gradient(135deg, rgb(var(--surface-tint-rgb)) 0%, rgb(var(--sky-pale-rgb)) 38%, rgb(var(--surface-sky-rgb)) 66%, rgb(var(--surface-rgb)) 100%); + linear-gradient( + 135deg, + rgb(var(--surface-tint-rgb)) 0%, + rgb(var(--sky-pale-rgb)) 38%, + rgb(var(--surface-sky-rgb)) 66%, + rgb(var(--surface-rgb)) 100% + ); color: rgb(var(--ink-deep-rgb)); box-shadow: 0 0 0 4px rgb(var(--sky-rgb) / 0.16), @@ -1584,11 +1671,16 @@ width: 22px; height: 22px; border-radius: 6px; - background: color-mix(in srgb, var(--layer-accent, rgb(var(--accent-deep-rgb))) 88%, rgb(var(--surface-rgb))); + background: color-mix( + in srgb, + var(--layer-accent, rgb(var(--accent-deep-rgb))) 88%, + rgb(var(--surface-rgb)) + ); color: rgb(var(--highlight-rgb)); font-size: 11px; font-weight: 900; - box-shadow: 0 7px 16px color-mix(in srgb, var(--layer-accent, rgb(var(--accent-deep-rgb))) 18%, transparent); + box-shadow: 0 7px 16px + color-mix(in srgb, var(--layer-accent, rgb(var(--accent-deep-rgb))) 18%, transparent); } .crystallizer-list strong { @@ -1658,7 +1750,7 @@ background: radial-gradient(circle at 35% 20%, rgb(var(--surface-rgb) / 0.9), transparent 44%), rgb(var(--accent-deep-rgb) / 0.12); - color: rgb(var(--text-accent-rgb)); + color: rgb(var(--ink-navy-rgb)); font-size: 10px; font-weight: bold; } @@ -1677,7 +1769,7 @@ max-height: 0; margin: 0; overflow: hidden; - color: rgb(var(--slate-light-rgb)); + color: var(--muted); font-size: 11px; font-weight: 760; opacity: 0; diff --git a/src/renderer/src/assets/styles/dashboard-captures.css b/src/renderer/src/assets/styles/dashboard-captures.css index 1872dfb..b8133b2 100644 --- a/src/renderer/src/assets/styles/dashboard-captures.css +++ b/src/renderer/src/assets/styles/dashboard-captures.css @@ -40,14 +40,18 @@ inset: 0; pointer-events: none; content: ''; - background-image: radial-gradient(circle, rgb(var(--surface-rgb) / 0.96) 0 1.2px, transparent 1.5px); + background-image: radial-gradient( + circle, + rgb(var(--surface-rgb) / 0.96) 0 1.2px, + transparent 1.5px + ); background-size: 64px 64px; mask-image: linear-gradient(to bottom, rgb(var(--mask-rgb)), transparent 86%); } .dashboard-hero p, .panel-header p { - color: black; + color: rgb(var(--hero-text-rgb)); font-size: 22px; font-family: var(--font-display); letter-spacing: 0; @@ -170,7 +174,8 @@ animation: discoverTextFlow 24s cubic-bezier(0.45, 0, 0.2, 1) infinite; filter: drop-shadow(0 1px 0 rgb(var(--highlight-rgb) / 0.7)) - drop-shadow(0 8px 16px rgb(var(--ink-abyss-rgb) / 0.16)) drop-shadow(0 0 14px rgb(var(--glow-rgb) / 0.16)); + drop-shadow(0 8px 16px rgb(var(--ink-abyss-rgb) / 0.16)) + drop-shadow(0 0 14px rgb(var(--glow-rgb) / 0.16)); } @keyframes discoverTextFlow { @@ -468,7 +473,12 @@ width: 7px; height: 7px; border-radius: 999px; - background: radial-gradient(circle, rgb(var(--surface-rgb)) 0 24%, rgb(var(--sky-rgb) / 0.8) 38%, transparent 72%); + background: radial-gradient( + circle, + rgb(var(--surface-rgb)) 0 24%, + rgb(var(--sky-rgb) / 0.8) 38%, + transparent 72% + ); filter: drop-shadow(0 0 10px rgb(var(--sky-rgb) / 0.9)); animation: startOrbSpark 4.8s ease-in-out infinite; } diff --git a/src/renderer/src/assets/styles/foundation.css b/src/renderer/src/assets/styles/foundation.css index dc96071..2059bb3 100644 --- a/src/renderer/src/assets/styles/foundation.css +++ b/src/renderer/src/assets/styles/foundation.css @@ -8,8 +8,9 @@ mean 50 tokens for one colour; one channel plus the call site's own alpha covers every one of them and flips the whole app with a single override. - Every light-mode channel below is the exact value it replaced, so the light - theme is unchanged by construction — see the `theme-tokens` test. + Most light-mode channels preserve the values they replaced. Text channels + called out below are intentionally adjusted where the rendered foreground and + frosted surface did not clear WCAG AA. Dark values are set in the `prefers-color-scheme` block further down, and `:root[data-theme]` beats both so an explicit choice always wins. @@ -47,7 +48,8 @@ /* --- Ink ----------------------------------------------------------------- */ --ink-rgb: 23 36 58; --soft-ink-rgb: 52 71 102; - --muted-rgb: 113 129 154; + /* Secondary copy still clears WCAG AA against the palest frosted surfaces. */ + --muted-rgb: 92 108 135; --faint-rgb: 157 170 190; --night-rgb: 16 32 51; --ink-deep-rgb: 8 47 73; @@ -63,7 +65,7 @@ --slate-light-rgb: 148 163 184; --slate-deep-rgb: 71 85 105; --slate-darkest-rgb: 51 65 85; - --slate-blue-rgb: 94 125 148; + --slate-blue-rgb: 86 117 141; --slate-steel-rgb: 65 97 119; --slate-ocean-rgb: 79 113 140; --slate-cool-rgb: 123 139 166; @@ -84,7 +86,7 @@ The identity. Most survive a dark background as-is; the few that go muddy or glaring are overridden in the dark block. */ --accent-rgb: 78 184 223; - --accent-strong-rgb: 36 127 167; + --accent-strong-rgb: 30 116 156; --accent-deep-rgb: 15 140 194; --accent-vivid-rgb: 14 165 233; --accent-mid-rgb: 75 163 206; @@ -149,17 +151,18 @@ changes nothing here. */ --text-ocean-rgb: 51 103 137; --text-steel-rgb: 94 132 185; - --text-accent-rgb: 15 140 194; + --text-accent-rgb: 30 116 156; /* The ink end of the ÆTHER wordmark gradients. A letterform's dark end is text, not a shadow, so it has to invert — left on a shadow channel it renders the first three letters invisible against the dark page. */ --wordmark-ink-rgb: 0 0 0; --wordmark-ink-soft-rgb: 16 40 70; + --hero-text-rgb: 0 0 0; /* Success text on a mint badge. Two near-identical greens meaning the same thing were merged here, so the state reads consistently. */ - --text-success-rgb: 43 125 100; + --text-success-rgb: 41 122 97; --shadow: 0 18px 55px rgb(var(--shadow-rgb) / 0.16); --soft-shadow: 0 10px 30px rgb(var(--shadow-soft-rgb) / 0.11); @@ -186,18 +189,20 @@ --surface-sky (a themed surface, which correctly inverts to navy in dark mode) with --accent-light/--azure (unthemed, which stay pale). The result in dark mode was a gradient running from near-black to pale blue with unreadable label text. - Light values below are byte-identical to the literals they replaced. */ + The light endpoint is lifted enough for dark label ink to clear WCAG AA. */ --primary-from-rgb: 224 242 254; --primary-from-hover-rgb: 229 248 255; --primary-mid-rgb: 106 193 229; - --primary-to-rgb: 54 122 178; + --primary-to-rgb: 88 180 220; --primary-edge-rgb: 54 122 178; + /* The pale light-theme gradient needs dark ink; dark mode flips this to the + existing frosted label colour without changing either gradient. */ + --primary-label-rgb: 18 63 92; /* The dark end of the iCE Crystallize button. It previously used --ink-abyss, which is an *ink* channel and therefore inverts to near-white in dark mode — under light label text that made the button unreadable. This stays dark in both themes because the button is dark by design, not by theme. */ --crystal-deep-rgb: 15 23 42; - } /* --------------------------------------------------------------------------- @@ -281,14 +286,16 @@ --wordmark-ink-rgb: 214 233 250; --wordmark-ink-soft-rgb: 150 196 226; - /* Deep enough that --highlight (198 220 246) clears 4.5:1 on every stop: + --hero-text-rgb: 228 237 249; + /* Deep enough that --highlight (198 220 246) clears 4.5:1 on every stop: from 8.40:1, mid 4.96:1, to 7.07:1. */ - --primary-from-rgb: 16 58 92; - --primary-from-hover-rgb: 22 72 112; - --primary-mid-rgb: 28 94 138; - --primary-to-rgb: 18 70 108; - --primary-edge-rgb: 96 168 208; - --crystal-deep-rgb: 10 17 30; + --primary-from-rgb: 16 58 92; + --primary-from-hover-rgb: 22 72 112; + --primary-mid-rgb: 28 94 138; + --primary-to-rgb: 18 70 108; + --primary-edge-rgb: 96 168 208; + --primary-label-rgb: 198 220 246; + --crystal-deep-rgb: 10 17 30; --text-success-rgb: 118 214 178; --success-rgb: 96 200 162; --aurora-deep-rgb: 92 198 166; @@ -378,6 +385,7 @@ --wordmark-ink-rgb: 214 233 250; --wordmark-ink-soft-rgb: 150 196 226; + --hero-text-rgb: 228 237 249; /* Deep enough that --highlight (198 220 246) clears 4.5:1 on every stop: from 8.40:1, mid 4.96:1, to 7.07:1. */ --primary-from-rgb: 16 58 92; @@ -385,6 +393,7 @@ --primary-mid-rgb: 28 94 138; --primary-to-rgb: 18 70 108; --primary-edge-rgb: 96 168 208; + --primary-label-rgb: 198 220 246; --crystal-deep-rgb: 10 17 30; --text-success-rgb: 118 214 178; --success-rgb: 96 200 162; @@ -1096,7 +1105,7 @@ button { rgb(var(--primary-mid-rgb)) 52%, rgb(var(--primary-to-rgb)) ); - color: rgb(var(--highlight-rgb)); + color: rgb(var(--primary-label-rgb)); box-shadow: inset 0 1px 0 rgb(var(--highlight-rgb) / 0.5), inset 0 -1px 0 rgb(var(--ocean-deep-rgb) / 0.18), diff --git a/src/renderer/src/assets/styles/intelligence-panel.css b/src/renderer/src/assets/styles/intelligence-panel.css index a309b7a..77fd284 100644 --- a/src/renderer/src/assets/styles/intelligence-panel.css +++ b/src/renderer/src/assets/styles/intelligence-panel.css @@ -60,13 +60,21 @@ background: radial-gradient(circle at 50% 18%, rgb(var(--surface-rgb) / 0.86), transparent 20%), radial-gradient(circle at 28% 46%, rgb(var(--glow-pale-rgb) / 0.42), transparent 32%), - linear-gradient(180deg, rgb(var(--surface-rgb) / 0.16), rgb(var(--surface-tint-cool-rgb) / 0.18)); + linear-gradient( + 180deg, + rgb(var(--surface-rgb) / 0.16), + rgb(var(--surface-tint-cool-rgb) / 0.18) + ); filter: blur(10px); transform: translateX(20px) scaleX(0.9); } .intelligence-panel::after { - background-image: radial-gradient(circle, rgb(var(--surface-rgb) / 0.85) 0 1px, transparent 1.5px); + background-image: radial-gradient( + circle, + rgb(var(--surface-rgb) / 0.85) 0 1px, + transparent 1.5px + ); background-size: 42px 42px; mask-image: linear-gradient(to bottom, transparent 0%, rgb(var(--mask-rgb)) 18%, transparent 94%); transform: translateY(18px); @@ -135,7 +143,7 @@ .status-pill.online { background: rgb(var(--success-rgb) / 0.1); - color: var(--success); + color: rgb(var(--text-success-rgb)); } .status-pill.offline { @@ -230,7 +238,11 @@ padding: 6px 8px 6px 10px; background: radial-gradient(circle at 18% 0%, rgb(var(--surface-rgb) / 0.98), transparent 42%), - linear-gradient(135deg, rgb(var(--surface-rgb) / 0.98), rgb(var(--surface-tint-cool-rgb) / 0.96)), + linear-gradient( + 135deg, + rgb(var(--surface-rgb) / 0.98), + rgb(var(--surface-tint-cool-rgb) / 0.96) + ), rgb(var(--surface-rgb) / 0.96); box-shadow: 0 0 0 4px rgba(67, 158, 221, 0.12), @@ -553,7 +565,7 @@ } .ask-current-radio.is-on { - border: 4px solid purple; + border: 4px solid var(--prism); } .ask-current-radio.is-on::after { @@ -561,7 +573,7 @@ width: 6px; height: 6px; border-radius: 50%; - background: purple; + background: var(--prism); } .ask-current-button.active, @@ -632,7 +644,7 @@ background: radial-gradient(circle at 16% 20%, rgb(var(--surface-rgb) / 0.95), transparent 36%), rgb(var(--surface-tint-rgb) / 0.78); - color: purple !important; + color: var(--ink) !important; } .ask-hub-picker button:hover { border-color: rgb(var(--accent-muted-rgb) / 0.3); @@ -1003,8 +1015,20 @@ background-size: 100% 96px; filter: blur(1.4px); opacity: 0.7; - -webkit-mask-image: linear-gradient(180deg, transparent, rgb(var(--mask-rgb)) 12%, rgb(var(--mask-rgb)) 88%, transparent); - mask-image: linear-gradient(180deg, transparent, rgb(var(--mask-rgb)) 12%, rgb(var(--mask-rgb)) 88%, transparent); + -webkit-mask-image: linear-gradient( + 180deg, + transparent, + rgb(var(--mask-rgb)) 12%, + rgb(var(--mask-rgb)) 88%, + transparent + ); + mask-image: linear-gradient( + 180deg, + transparent, + rgb(var(--mask-rgb)) 12%, + rgb(var(--mask-rgb)) 88%, + transparent + ); animation: flowStream 3.6s linear infinite; } @@ -1202,7 +1226,11 @@ border-radius: 14px; background: radial-gradient(circle at 50% 28%, rgb(var(--surface-rgb) / 0.94), transparent 26%), - linear-gradient(145deg, rgb(var(--surface-tint-warm-rgb) / 0.9), rgb(var(--surface-tint-cool-rgb) / 0.76)); + linear-gradient( + 145deg, + rgb(var(--surface-tint-warm-rgb) / 0.9), + rgb(var(--surface-tint-cool-rgb) / 0.76) + ); box-shadow: inset 0 1px 0 rgb(var(--highlight-rgb) / 0.82), 0 16px 42px rgba(84, 160, 199, 0.12); @@ -1626,7 +1654,11 @@ border: 1px solid rgb(var(--accent-soft-rgb) / 0.35); border-radius: 999px; padding: 0 12px; - background: linear-gradient(145deg, rgb(var(--surface-tint-cool-rgb) / 0.95), rgb(var(--surface-rgb) / 0.7)); + background: linear-gradient( + 145deg, + rgb(var(--surface-tint-cool-rgb) / 0.95), + rgb(var(--surface-rgb) / 0.7) + ); color: rgb(var(--ocean-rgb)); font-size: 11px; font-weight: 860; @@ -1663,7 +1695,11 @@ margin-top: 4px; border: 1px solid rgb(var(--accent-soft-rgb) / 0.35); border-radius: 10px; - background: linear-gradient(145deg, rgb(var(--surface-tint-cool-rgb) / 0.9), rgb(var(--surface-rgb) / 0.68)); + background: linear-gradient( + 145deg, + rgb(var(--surface-tint-cool-rgb) / 0.9), + rgb(var(--surface-rgb) / 0.68) + ); color: rgb(var(--ocean-rgb)); font-size: 11px; font-weight: 800; diff --git a/src/renderer/src/assets/styles/knowledge-shelves.css b/src/renderer/src/assets/styles/knowledge-shelves.css index 3c78657..aa6d1c1 100644 --- a/src/renderer/src/assets/styles/knowledge-shelves.css +++ b/src/renderer/src/assets/styles/knowledge-shelves.css @@ -134,7 +134,12 @@ z-index: -1; content: ''; background: - linear-gradient(118deg, transparent 0 38%, rgb(var(--sky-soft-rgb) / 0.14) 39% 40%, transparent 41%), + linear-gradient( + 118deg, + transparent 0 38%, + rgb(var(--sky-soft-rgb) / 0.14) 39% 40%, + transparent 41% + ), radial-gradient(circle at 40% 0%, rgb(var(--surface-rgb) / 0.86), transparent 30%); opacity: 0.72; } @@ -231,7 +236,11 @@ border-radius: 8px; background: radial-gradient(circle at 35% 20%, rgb(var(--surface-rgb) / 0.96), transparent 44%), - linear-gradient(145deg, rgb(var(--surface-tint-cool-rgb) / 0.9), rgb(var(--sky-mist-rgb) / 0.42)); + linear-gradient( + 145deg, + rgb(var(--surface-tint-cool-rgb) / 0.9), + rgb(var(--sky-mist-rgb) / 0.42) + ); color: rgb(var(--accent-strong-rgb)); pointer-events: none; box-shadow: @@ -344,7 +353,11 @@ padding: 8px 16px; border: 1px solid rgb(var(--accent-mid-rgb) / 0.44); border-radius: 11px; - background: linear-gradient(145deg, rgb(var(--surface-tint-rgb) / 0.98), rgb(var(--sky-pale-rgb) / 0.94)); + background: linear-gradient( + 145deg, + rgb(var(--surface-tint-rgb) / 0.98), + rgb(var(--sky-pale-rgb) / 0.94) + ); font-weight: 660; color: rgb(var(--ink-navy-rgb)); cursor: pointer; @@ -407,7 +420,11 @@ padding: 10px 12px; border: 1px solid rgb(var(--accent-mid-rgb) / 0.24); border-radius: 12px; - background: linear-gradient(145deg, rgb(var(--surface-rgb) / 0.96), rgb(var(--surface-tint-rgb) / 0.8)); + background: linear-gradient( + 145deg, + rgb(var(--surface-rgb) / 0.96), + rgb(var(--surface-tint-rgb) / 0.8) + ); text-align: left; font-weight: 500; cursor: pointer; @@ -415,7 +432,11 @@ .library-search-results button:hover { border-color: rgb(var(--accent-mid-rgb) / 0.5); - background: linear-gradient(145deg, rgb(var(--surface-rgb) / 1), rgb(var(--surface-sky-rgb) / 0.94)); + background: linear-gradient( + 145deg, + rgb(var(--surface-rgb) / 1), + rgb(var(--surface-sky-rgb) / 0.94) + ); } .library-search-hit-head { @@ -462,7 +483,11 @@ padding: 10px 12px; border: 1px solid rgb(var(--accent-mid-rgb) / 0.26); border-radius: 14px; - background: linear-gradient(145deg, rgb(var(--surface-rgb) / 0.94), rgb(var(--surface-tint-cool-rgb) / 0.72)); + background: linear-gradient( + 145deg, + rgb(var(--surface-rgb) / 0.94), + rgb(var(--surface-tint-cool-rgb) / 0.72) + ); box-shadow: inset 0 1px 0 rgb(var(--highlight-rgb) / 0.9); } @@ -509,7 +534,11 @@ padding: 8px 14px; border: 1px solid rgb(var(--accent-mid-rgb) / 0.4); border-radius: 10px; - background: linear-gradient(145deg, rgb(var(--surface-tint-rgb) / 0.98), rgb(var(--sky-pale-rgb) / 0.92)); + background: linear-gradient( + 145deg, + rgb(var(--surface-tint-rgb) / 0.98), + rgb(var(--sky-pale-rgb) / 0.92) + ); font: inherit; font-size: 13px; font-weight: 650; @@ -533,7 +562,7 @@ flex: 1 0 100%; font-size: 11px; font-weight: 600; - color: rgb(var(--text-ocean-rgb) / 0.72); + color: var(--muted); } @media (prefers-reduced-motion: reduce) { @@ -653,7 +682,11 @@ border-color: rgb(var(--steel-rgb) / 0.3); background: radial-gradient(circle at 30% 18%, rgb(var(--surface-rgb) / 0.9), transparent 42%), - linear-gradient(135deg, rgb(var(--surface-tint-cool-rgb) / 0.95), rgb(var(--surface-rgb) / 0.82)); + linear-gradient( + 135deg, + rgb(var(--surface-tint-cool-rgb) / 0.95), + rgb(var(--surface-rgb) / 0.82) + ); color: var(--accent-strong); font-weight: 800; } diff --git a/src/renderer/src/assets/styles/setup-settings.css b/src/renderer/src/assets/styles/setup-settings.css index 5d4a561..b481c3d 100644 --- a/src/renderer/src/assets/styles/setup-settings.css +++ b/src/renderer/src/assets/styles/setup-settings.css @@ -44,9 +44,7 @@ card overhangs its own wrapper and eats into the backdrop gutter on the right. */ box-sizing: border-box; width: 100%; - max-height: calc(100vh - 150px); - overflow-x: hidden; - overflow-y: auto; + overflow: hidden; border: 1px solid rgb(var(--accent-sky-rgb) / 0.32); border-radius: 26px; padding: 18px; @@ -92,15 +90,6 @@ mask-image: linear-gradient(180deg, rgb(var(--mask-rgb) / 0.95), rgb(var(--mask-rgb) / 0.48)); } -.model-setup-glass { - position: absolute; - inset: 10px; - z-index: -1; - border: 1px solid rgb(var(--edge-rgb) / 0.68); - border-radius: 20px; - pointer-events: none; -} - .model-setup-hero { display: grid; grid-template-columns: minmax(0, 1fr) minmax(180px, 250px); @@ -736,6 +725,8 @@ .model-setup-modal { max-height: calc(100vh - 122px); + overflow-x: hidden; + overflow-y: auto; padding: 14px; } @@ -771,6 +762,62 @@ } } +@media (min-width: 881px) and (max-height: 760px) { + .model-setup-overlay { + padding: 18px; + } + + .model-setup-shell { + gap: 10px; + max-height: calc(100vh - 36px); + } + + .model-setup-modal { + gap: 10px; + padding: 14px; + } + + .model-setup-hero { + min-height: 118px; + } + + #model-setup-title { + font-size: 48px; + } + + .model-setup-crystal { + min-height: 116px; + } + + .model-setup-crystal img { + max-height: 120px; + } + + .model-choice { + min-height: 84px; + padding: 8px 10px; + } + + .model-setup-side { + gap: 8px; + } + + .model-core-card, + .model-access-card, + .model-dir-card { + gap: 5px; + padding: 10px; + } + + .model-access-card { + gap: 6px; + } + + .model-access-links a { + padding: 3px 8px; + } +} + @media (max-width: 560px) { .model-setup-copy h1 { font-size: 40px; @@ -1124,6 +1171,75 @@ -webkit-line-clamp: 5; } +.settings-changelog-link { + display: inline-grid; + width: fit-content; + max-width: 100%; + grid-template-columns: 30px minmax(0, 1fr); + align-items: center; + gap: 9px; + border: 1px solid rgb(var(--line-strong-rgb) / 0.3); + border-radius: 13px; + padding: 7px 12px 7px 8px; + background: + radial-gradient(circle at 10% 0%, rgb(var(--highlight-rgb) / 0.2), transparent 46%), + rgb(var(--surface-sky-rgb) / 0.38); + color: var(--ink); + text-decoration: none; + box-shadow: inset 0 1px 0 rgb(var(--highlight-rgb) / 0.14); + transition: + border-color 160ms ease, + background 160ms ease, + box-shadow 160ms ease, + transform 160ms ease; +} + +.settings-changelog-link:hover { + border-color: rgb(var(--line-strong-rgb) / 0.56); + background: + radial-gradient(circle at 10% 0%, rgb(var(--highlight-rgb) / 0.28), transparent 46%), + rgb(var(--surface-sky-rgb) / 0.58); + box-shadow: + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.2), + 0 8px 20px rgb(var(--shadow-soft-rgb) / 0.14); + transform: translateY(-1px); +} + +.settings-changelog-icon { + display: grid; + place-items: center; + width: 30px; + height: 30px; + border-radius: 9px; + background: rgb(var(--surface-tint-cool-rgb) / 0.72); + color: rgb(var(--text-accent-rgb)); +} + +.settings-changelog-icon svg { + width: 15px; + height: 15px; +} + +.settings-changelog-link > span:last-child { + display: grid; + min-width: 0; + gap: 1px; +} + +.settings-changelog-link strong { + font-size: 11px; + font-weight: 900; +} + +.settings-changelog-link small { + overflow: hidden; + color: var(--muted); + font-size: 10px; + font-weight: 760; + text-overflow: ellipsis; + white-space: nowrap; +} + /* A glance at recent failures, not a log viewer — capped in the markup at 12 rows and scrollable, so a bad session cannot push the rest of Settings off-screen. */ .settings-diagnostics-log { diff --git a/src/renderer/src/assets/styles/shared-overrides.css b/src/renderer/src/assets/styles/shared-overrides.css index f370bd5..30a4fb9 100644 --- a/src/renderer/src/assets/styles/shared-overrides.css +++ b/src/renderer/src/assets/styles/shared-overrides.css @@ -254,7 +254,13 @@ border-color: rgb(var(--sky-rgb) / 0.34); background: radial-gradient(circle at 80% 12%, rgb(var(--surface-rgb) / 0.98), transparent 24%), - linear-gradient(135deg, rgb(var(--surface-tint-rgb)) 0%, rgb(var(--sky-pale-rgb)) 38%, rgb(var(--surface-sky-rgb)) 66%, rgb(var(--surface-rgb)) 100%) !important; + linear-gradient( + 135deg, + rgb(var(--surface-tint-rgb)) 0%, + rgb(var(--sky-pale-rgb)) 38%, + rgb(var(--surface-sky-rgb)) 66%, + rgb(var(--surface-rgb)) 100% + ) !important; color: rgb(var(--marine-rgb)); isolation: isolate; transition: @@ -461,7 +467,7 @@ rgb(var(--primary-mid-rgb) / 0.94) 48%, rgb(var(--primary-to-rgb) / 0.92) ); - color: rgb(var(--highlight-rgb)); + color: rgb(var(--primary-label-rgb)); box-shadow: inset 0 1px 0 rgb(var(--highlight-rgb) / 0.62), 0 12px 26px rgb(var(--accent-ocean-rgb) / 0.16); @@ -471,6 +477,7 @@ .danger-primary { border-color: rgb(var(--danger-rgb) / 0.25); background: linear-gradient(180deg, #d87a78, rgb(var(--danger-rgb))); + color: rgb(var(--highlight-rgb)); } button:disabled, @@ -478,11 +485,7 @@ button:disabled:hover { border-color: rgb(var(--slate-cool-rgb) / 0.18); background: radial-gradient(circle at 32% 18%, rgb(var(--surface-rgb) / 0.7), transparent 42%), - linear-gradient( - 145deg, - rgb(var(--surface-sky-rgb) / 0.72), - rgb(var(--surface-tint-rgb) / 0.62) - ); + linear-gradient(145deg, rgb(var(--surface-sky-rgb) / 0.72), rgb(var(--surface-tint-rgb) / 0.62)); color: rgb(var(--slate-steel-rgb) / 0.58); box-shadow: none; cursor: not-allowed; diff --git a/src/renderer/src/assets/styles/start-page.css b/src/renderer/src/assets/styles/start-page.css index 7ad3e56..4b391ab 100644 --- a/src/renderer/src/assets/styles/start-page.css +++ b/src/renderer/src/assets/styles/start-page.css @@ -232,9 +232,14 @@ } .hub-shortcut.drop-target .hub-launch { - border-color: color-mix(in srgb, var(--portal-tint, var(--accent)) 54%, rgb(var(--edge-rgb) / 0.5)); + border-color: color-mix( + in srgb, + var(--portal-tint, var(--accent)) 54%, + rgb(var(--edge-rgb) / 0.5) + ); box-shadow: - 0 18px 36px color-mix(in srgb, var(--portal-tint, var(--accent)) 22%, rgb(var(--shadow-soft-rgb) / 0.12)), + 0 18px 36px + color-mix(in srgb, var(--portal-tint, var(--accent)) 22%, rgb(var(--shadow-soft-rgb) / 0.12)), 0 0 0 4px color-mix(in srgb, var(--portal-tint, var(--accent)) 18%, transparent); transform: translateY(-2px) scale(1.018); } @@ -251,22 +256,15 @@ row-gap: 2px; border-radius: 8px; padding: 8px 9px; - border-color: color-mix( - in srgb, - var(--portal-tint, var(--accent)) 28%, - rgb(var(--steel-mid-rgb) / 0.22) - ); + border-color: rgb(var(--line-strong-rgb) / 0.32); background: - radial-gradient(circle at 50% 0%, rgb(var(--surface-rgb) / 0.96), transparent 48%), - radial-gradient( - circle at 88% 90%, - color-mix(in srgb, var(--portal-tint, var(--accent)) 18%, transparent), - transparent 42% - ), - rgb(var(--surface-rgb) / 0.58); + radial-gradient(circle at 18% 0%, rgb(var(--highlight-rgb) / 0.22), transparent 46%), + linear-gradient(145deg, rgb(var(--surface-sky-rgb) / 0.84), rgb(var(--surface-tint-rgb) / 0.7)); color: var(--ink); text-align: left; - box-shadow: 0 10px 22px rgb(var(--steel-mid-rgb) / 0.08); + box-shadow: + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.18), + 0 10px 22px rgb(var(--shadow-soft-rgb) / 0.14); transition: transform 160ms ease, border-color 160ms ease, @@ -275,21 +273,17 @@ } .hub-launch:hover { - border-color: color-mix( - in srgb, - var(--portal-tint, var(--accent)) 46%, - rgb(var(--edge-rgb) / 0.46) - ); + border-color: rgb(var(--line-strong-rgb) / 0.58); background: - radial-gradient(circle at 50% 0%, rgb(var(--surface-rgb) / 0.98), transparent 48%), - radial-gradient( - circle at 88% 90%, - color-mix(in srgb, var(--portal-tint, var(--accent)) 24%, transparent), - transparent 42% - ), - rgb(var(--surface-rgb) / 0.86); - box-shadow: 0 6px 14px - color-mix(in srgb, var(--portal-tint, var(--accent)) 18%, rgb(var(--shadow-soft-rgb) / 0.12)); + radial-gradient(circle at 18% 0%, rgb(var(--highlight-rgb) / 0.28), transparent 46%), + linear-gradient( + 145deg, + rgb(var(--surface-sky-rgb) / 0.96), + rgb(var(--surface-tint-cool-rgb) / 0.82) + ); + box-shadow: + inset 0 1px 0 rgb(var(--highlight-rgb) / 0.24), + 0 8px 18px rgb(var(--accent-ocean-rgb) / 0.18); transform: translateY(-1px); } @@ -301,9 +295,9 @@ height: 28px; border-radius: 8px; background: - radial-gradient(circle at 35% 22%, rgb(var(--surface-rgb)), transparent 44%), - linear-gradient(145deg, rgb(var(--surface-tint-cool-rgb)), rgb(var(--surface-rgb))); - color: var(--accent-strong); + radial-gradient(circle at 35% 22%, rgb(var(--highlight-rgb) / 0.3), transparent 44%), + linear-gradient(145deg, rgb(var(--surface-tint-cool-rgb)), rgb(var(--surface-sky-rgb))); + color: rgb(var(--text-accent-rgb)); font-family: var(--font-old); font-size: 14px; font-weight: 700; diff --git a/src/renderer/src/components/ModelSetupModal.tsx b/src/renderer/src/components/ModelSetupModal.tsx index f801e3a..8b9fd5c 100644 --- a/src/renderer/src/components/ModelSetupModal.tsx +++ b/src/renderer/src/components/ModelSetupModal.tsx @@ -155,7 +155,6 @@ export function ModelSetupModal({ aria-modal="true" aria-labelledby="model-setup-title" > -