From 39ce9380d05b305492d0f0c90bbbc52504513c51 Mon Sep 17 00:00:00 2001 From: Yanlin Date: Sat, 26 Sep 2026 12:26:54 -0700 Subject: [PATCH 1/7] Add browse and edit operations for the web UI Note, topic and topic-list reads; topic rename and merge, annotation edit and delete, and conflict resolution that sets the losing copies aside outside the vault. View types derive ts-rs so the web UI's API types are generated. Co-Authored-By: Claude Opus 5.5 --- .cargo/config.toml | 3 + crates/distill-core/CHANGELOG.md | 8 ++ crates/distill-core/Cargo.toml | 1 + crates/distill-core/README.md | 10 +- crates/distill-core/src/config.rs | 33 ++++- crates/distill-core/src/edit.rs | 161 ++++++++++++++++++++++++ crates/distill-core/src/error.rs | 26 ++++ crates/distill-core/src/index/browse.rs | 144 +++++++++++++++++++++ crates/distill-core/src/index/mod.rs | 2 + crates/distill-core/src/index/query.rs | 125 ++++++++++-------- crates/distill-core/src/lib.rs | 1 + crates/distill-core/src/model.rs | 7 +- crates/distill-core/src/ops.rs | 1 + crates/distill-core/src/sources.rs | 3 +- crates/distill-core/src/vault.rs | 6 + crates/distill-core/tests/common/mod.rs | 54 ++++++++ crates/distill-core/tests/edit.rs | 148 ++++++++++++++++++++++ crates/distill-core/tests/save.rs | 54 +------- 18 files changed, 679 insertions(+), 108 deletions(-) create mode 100644 .cargo/config.toml create mode 100644 crates/distill-core/src/edit.rs create mode 100644 crates/distill-core/src/index/browse.rs create mode 100644 crates/distill-core/tests/common/mod.rs create mode 100644 crates/distill-core/tests/edit.rs diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..ecdbac4 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,3 @@ +# ts-rs writes the web UI's API types here when `cargo test` runs (AGENTS.md: API types). +[env] +TS_RS_EXPORT_DIR = { value = "apps/web/src/api/generated", relative = true } diff --git a/crates/distill-core/CHANGELOG.md b/crates/distill-core/CHANGELOG.md index 5d0ffc8..b70fb55 100644 --- a/crates/distill-core/CHANGELOG.md +++ b/crates/distill-core/CHANGELOG.md @@ -18,3 +18,11 @@ verification against Codex rollouts and `CLAUDE_CODE_SESSION_ID`, reopen links, and detection of the installed plugin. `doctor` reports a missing plugin. - `schema` feature: JSON Schema for `SaveRequest`, used as the MCP tool schema. +- Reads for the web UI: `Index::note_detail` (body, file, topic label, ask count, reopen + links), `Index::topic_detail` (timeline, sessions, merged-in topics) and `Index::topics`. +- Edits for the web UI: `Distill::rename_topic`, `merge_topic`, `update_annotation`, + `delete_annotation` and `resolve_conflict`. Settling a conflict moves the other copies to + `/discarded//` instead of deleting them. +- `ui.autostart` setting (default `true`) and `LocalConfig::ui_origin`. +- TypeScript types for every API view type, generated by ts-rs into + `apps/web/src/api/generated` when `cargo test` runs. diff --git a/crates/distill-core/Cargo.toml b/crates/distill-core/Cargo.toml index bc35eba..36ef187 100644 --- a/crates/distill-core/Cargo.toml +++ b/crates/distill-core/Cargo.toml @@ -19,6 +19,7 @@ strsim = "0.11.1" tempfile = "3.27.0" thiserror = "2.0.21" toml = "1.1.6" +ts-rs = "12.0.1" ulid = "3.0.0" walkdir = "2.5.0" yaml_serde = "0.10.7" diff --git a/crates/distill-core/README.md b/crates/distill-core/README.md index 9c0109c..fb6ce1d 100644 --- a/crates/distill-core/README.md +++ b/crates/distill-core/README.md @@ -12,17 +12,25 @@ the per-device SQLite index derived from it, and the operations that read and wr | `ops::init` | Create or join a vault and write this device's config. Idempotent | | `Distill::open` | Load config, open the vault, open and sync the index | | `Distill::save` | Validate a `SaveRequest`, write topic and note files, return `SaveResult` | -| `Distill::annotate` | Add an annotation to a note | +| `Distill::annotate` / `update_annotation` / `delete_annotation` | Add, rewrite or remove an annotation | +| `Distill::rename_topic` | Rewrite a topic's label | +| `Distill::merge_topic` | Write `merged_into` into one topic's file; notes are untouched | +| `Distill::resolve_conflict` | Keep one copy of a conflicted file; move the others to `Dirs::discarded_dir` | | `Distill::move_vault` / `use_vault` | Copy the vault elsewhere, or switch to another vault | | `Distill::doctor` | Paths, installed plugin, conflicts, unreadable files | | `Index::recall` | Topics that resemble a question, with all their notes, annotations and every tag in use | | `Index::search` | Keyword search, optionally filtered by tag | | `Index::stats` / `tags` | Counts by topic, tag, ISO week and project | +| `Index::note_detail` / `topic_detail` / `topics` | What the web UI's note, topic and topic-list pages show | | `redact::redact` | Secret redaction applied before anything reaches the vault | | `sources` | Hook input and agent detection, injected context, source verification, reopen links, installed-plugin detection | File formats: [docs/vault-format.md](../../docs/vault-format.md). +Every type the web API returns derives `ts_rs::TS`. `cargo test` writes the TypeScript +versions to `apps/web/src/api/generated` (set by `.cargo/config.toml`); commit them with the +Rust change. + ## Save contract `SaveRequest` is JSON with `title`, `question`, `conclusion`, optional `key_points` and diff --git a/crates/distill-core/src/config.rs b/crates/distill-core/src/config.rs index bef5758..4cf4c3d 100644 --- a/crates/distill-core/src/config.rs +++ b/crates/distill-core/src/config.rs @@ -8,6 +8,9 @@ use crate::error::{Error, IoContext, Result}; use crate::fsutil::write_atomic; pub const DEFAULT_UI_PORT: u16 = 4777; +/// Host name of the web UI. `*.localhost` resolves to loopback without touching the hosts +/// file, and the session cookie is bound to it rather than to every `localhost` port. +pub const UI_HOST: &str = "distill.localhost"; /// Overrides both the config and data directories. Used by tests and by users who want /// several independent Distill setups on one machine. @@ -49,6 +52,11 @@ impl Dirs { pub fn index_file(&self, vault_id: &str) -> PathBuf { self.data_dir.join("index").join(format!("{vault_id}.db")) } + + /// Where copies set aside while settling a sync conflict go. Never synced. + pub fn discarded_dir(&self, vault_id: &str) -> PathBuf { + self.data_dir.join("discarded").join(vault_id) + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -67,12 +75,21 @@ pub struct LocalConfig { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UiConfig { pub port: u16, + /// Whether `distill mcp` starts the web server in the background before it hands the + /// agent a link, so the link opens. + #[serde(default = "enabled")] + pub autostart: bool, +} + +fn enabled() -> bool { + true } impl Default for UiConfig { fn default() -> Self { Self { port: DEFAULT_UI_PORT, + autostart: true, } } } @@ -89,7 +106,7 @@ impl Default for SuggestConfig { } /// Keys `distill config get/set` accepts. `vault` is changed with `distill vault use/move`. -pub const SETTABLE_KEYS: &[&str] = &["ui.port", "suggest.enabled"]; +pub const SETTABLE_KEYS: &[&str] = &["ui.port", "ui.autostart", "suggest.enabled"]; impl LocalConfig { pub fn load(dirs: &Dirs) -> Result { @@ -124,6 +141,7 @@ impl LocalConfig { "vault" => Ok(self.vault_path().display().to_string()), "device_id" => Ok(self.device_id.clone()), "ui.port" => Ok(self.ui.port.to_string()), + "ui.autostart" => Ok(self.ui.autostart.to_string()), "suggest.enabled" => Ok(self.suggest.enabled.to_string()), _ => Err(unknown_key(key)), } @@ -145,6 +163,11 @@ impl LocalConfig { } self.ui.port = port; } + "ui.autostart" => { + self.ui.autostart = value + .parse() + .map_err(|_| invalid("expected true or false"))?; + } "suggest.enabled" => { self.suggest.enabled = value .parse() @@ -156,7 +179,13 @@ impl LocalConfig { } pub fn note_url(&self, note_id: &str) -> String { - format!("http://distill.localhost:{}/notes/{note_id}", self.ui.port) + format!("{}/notes/{note_id}", self.ui_origin()) + } + + /// The fixed address of the web UI (spec D7). Links built on it stay valid because + /// the port is chosen once at init. + pub fn ui_origin(&self) -> String { + format!("http://{UI_HOST}:{}", self.ui.port) } } diff --git a/crates/distill-core/src/edit.rs b/crates/distill-core/src/edit.rs new file mode 100644 index 0000000..d09c925 --- /dev/null +++ b/crates/distill-core/src/edit.rs @@ -0,0 +1,161 @@ +//! Edits made from the web UI: rename and merge topics, change and delete annotations, and +//! settle sync conflicts. Each one rewrites or removes only the file it is about, so the +//! vault stays safe for file-sync tools (spec D2). + +use std::path::{Path, PathBuf}; + +use crate::error::{Error, IoContext, Result}; +use crate::fsutil::now_rfc3339; +use crate::model::{AnnotationMeta, TopicMeta}; +use crate::ops::Distill; +use crate::redact::redact; +use crate::vault::parse_md; + +impl Distill { + pub fn rename_topic(&mut self, id: &str, label: &str) -> Result<()> { + let label = label.trim(); + if label.is_empty() || label.contains('\n') { + return Err(Error::InvalidTopic { + id: id.to_string(), + reason: "the label must be one non-empty line".into(), + }); + } + let path = self.topic_path(id)?; + let (mut meta, description): (TopicMeta, String) = read_md(&path)?; + meta.label = redact(label); + self.vault.rewrite_md(&path, &meta, &description)?; + self.index.sync()?; + Ok(()) + } + + /// Merges the topic `from` belongs to into the topic `into` belongs to, by writing + /// `merged_into` into the first topic's file. Notes are not touched. Returns the topic + /// both now resolve to. + pub fn merge_topic(&mut self, from: &str, into: &str) -> Result { + for id in [from, into] { + if !self.index.topic_exists(id)? { + return Err(Error::UnknownTopic { id: id.to_string() }); + } + } + let source = self.index.resolve_topic(from)?; + let target = self.index.resolve_topic(into)?; + if source == target { + return Err(Error::InvalidMerge { + from: from.to_string(), + into: into.to_string(), + reason: format!("both already resolve to topic {target}"), + }); + } + let path = self.topic_path(&source)?; + let (mut meta, description): (TopicMeta, String) = read_md(&path)?; + meta.merged_into = Some(target.clone()); + self.vault.rewrite_md(&path, &meta, &description)?; + self.index.sync()?; + Ok(target) + } + + pub fn update_annotation(&mut self, id: &str, text: &str) -> Result<()> { + if text.trim().is_empty() { + return Err(Error::InvalidNote { + reason: format!( + "annotation {id} would be empty; delete it instead of saving no text" + ), + }); + } + let path = self.annotation_path(id)?; + let (mut meta, _): (AnnotationMeta, String) = read_md(&path)?; + meta.updated = Some(now_rfc3339()); + self.vault + .rewrite_md(&path, &meta, &format!("{}\n", redact(text.trim())))?; + self.index.sync()?; + Ok(()) + } + + pub fn delete_annotation(&mut self, id: &str) -> Result<()> { + let path = self.annotation_path(id)?; + std::fs::remove_file(&path).at(&path)?; + if let Some(dir) = path.parent() + && std::fs::read_dir(dir).at(dir)?.next().is_none() + { + std::fs::remove_dir(dir).at(dir)?; + } + self.index.sync()?; + Ok(()) + } + + /// Keeps `keep`, one of the files a sync tool left for the same `kind` and `id`, and + /// moves the other copies out of the vault into this device's data directory, where + /// they can still be recovered. Returns where the copies went. + pub fn resolve_conflict(&mut self, kind: &str, id: &str, keep: &str) -> Result> { + let conflict = self + .index + .conflicts()? + .into_iter() + .find(|c| c.kind == kind && c.id == id) + .ok_or_else(|| Error::UnknownConflict { + kind: kind.to_string(), + id: id.to_string(), + })?; + if !conflict.paths.iter().any(|p| p == keep) { + return Err(Error::NotAConflictCopy { + kind: kind.to_string(), + id: id.to_string(), + path: keep.to_string(), + paths: conflict.paths, + }); + } + let dest_root = self + .dirs + .discarded_dir(self.vault.id()) + .join(now_rfc3339().replace(':', "")); + let mut moved = Vec::new(); + for rel in conflict.paths.iter().filter(|p| *p != keep) { + let src = self.vault.root().join(rel); + let dest = dest_root.join(rel); + move_file(&src, &dest)?; + moved.push(dest); + } + self.index.sync()?; + Ok(moved) + } + + fn topic_path(&self, id: &str) -> Result { + let rel = self + .index + .topic_file(id)? + .ok_or_else(|| Error::UnknownTopic { id: id.to_string() })?; + Ok(self.vault.root().join(rel)) + } + + fn annotation_path(&self, id: &str) -> Result { + let (rel, _) = self + .index + .annotation_file(id)? + .ok_or_else(|| Error::UnknownAnnotation { id: id.to_string() })?; + Ok(self.vault.root().join(rel)) + } +} + +fn read_md(path: &Path) -> Result<(T, String)> { + let text = std::fs::read_to_string(path).at(path)?; + parse_md(path, &text) +} + +/// Renames, or copies and removes when the destination is on another volume (a vault on +/// a sync drive, the data directory on the system disk). +fn move_file(src: &Path, dest: &Path) -> Result<()> { + if let Some(dir) = dest.parent() { + std::fs::create_dir_all(dir).at(dir)?; + } + match std::fs::rename(src, dest) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::CrossesDevices => { + std::fs::copy(src, dest).at(dest)?; + std::fs::remove_file(src).at(src) + } + Err(e) => Err(Error::Io { + path: src.to_path_buf(), + source: e, + }), + } +} diff --git a/crates/distill-core/src/error.rs b/crates/distill-core/src/error.rs index ebfac75..01b8cc7 100644 --- a/crates/distill-core/src/error.rs +++ b/crates/distill-core/src/error.rs @@ -52,6 +52,32 @@ pub enum Error { #[error("unknown note {id}.")] UnknownNote { id: String }, + #[error("unknown annotation {id}.")] + UnknownAnnotation { id: String }, + + #[error("invalid topic {id}: {reason}")] + InvalidTopic { id: String, reason: String }, + + #[error("cannot merge topic {from} into {into}: {reason}")] + InvalidMerge { + from: String, + into: String, + reason: String, + }, + + #[error( + "no sync conflict for {kind} {id}. Reload the list; another device may have settled it." + )] + UnknownConflict { kind: String, id: String }, + + #[error("{path} is not one of the conflicting copies of {kind} {id}: {}", .paths.join(", "))] + NotAConflictCopy { + kind: String, + id: String, + path: String, + paths: Vec, + }, + #[error("unknown config key \"{key}\". Known keys: {known}.")] UnknownConfigKey { key: String, known: String }, diff --git a/crates/distill-core/src/index/browse.rs b/crates/distill-core/src/index/browse.rs new file mode 100644 index 0000000..328d7b9 --- /dev/null +++ b/crates/distill-core/src/index/browse.rs @@ -0,0 +1,144 @@ +//! Reads behind the web UI's pages: one note with everything its page shows, one topic with +//! its timeline, every topic, and the vault files behind topics and annotations. + +use std::collections::HashSet; + +use rusqlite::OptionalExtension; +use serde::Serialize; + +use super::Index; +use super::query::{NoteView, TopicCount, note_view, resolve_merge, topic_counts}; +use crate::error::Result; +use crate::sources::{Reopen, reopen}; + +#[derive(Debug, Clone, Serialize, ts_rs::TS)] +#[ts(export)] +pub struct NoteDetail { + pub note: NoteView, + /// The note body as written in the file, starting with `# `. + pub markdown: String, + /// Absolute path of the note file, for opening it in an editor. + pub file: String, + pub topic_label: String, + /// Notes in the note's topic, this one included. + pub ask_count: usize, + pub reopen: Reopen, +} + +#[derive(Debug, Clone, Serialize, ts_rs::TS)] +#[ts(export)] +pub struct TopicRef { + pub topic_id: String, + pub label: String, +} + +#[derive(Debug, Clone, Serialize, ts_rs::TS)] +#[ts(export)] +pub struct TopicDetail { + /// The topic after following `merged_into`; differs from the requested id when that + /// topic was merged away. + pub topic_id: String, + pub label: String, + pub created: String, + pub ask_count: usize, + /// Distinct agent sessions the question was distilled in. + pub sessions: usize, + /// Topics merged into this one. + pub merged_from: Vec<TopicRef>, + /// Oldest first, so the page reads as a timeline. + pub notes: Vec<NoteView>, +} + +impl Index { + pub fn note_detail(&self, id: &str) -> Result<Option<NoteDetail>> { + let Some(note) = self.note(id)? else { + return Ok(None); + }; + let markdown: String = + self.conn + .query_row("SELECT body FROM note_current WHERE id = ?1", [id], |r| { + r.get(0) + })?; + let label = self.topic_labels()?.remove(¬e.topic_id); + Ok(Some(NoteDetail { + markdown, + file: self.vault_root.join(¬e.path).display().to_string(), + topic_label: label.unwrap_or_default(), + ask_count: self.ask_count(¬e.topic_id)?, + reopen: reopen(¬e.source), + note, + })) + } + + pub fn topic_detail(&self, id: &str) -> Result<Option<TopicDetail>> { + if !self.topic_exists(id)? { + return Ok(None); + } + let merges = self.merges()?; + let topic_id = resolve_merge(id, &merges); + let (label, created): (String, String) = self.conn.query_row( + "SELECT label, created FROM topic_current WHERE id = ?1", + [&topic_id], + |r| Ok((r.get(0)?, r.get(1)?)), + )?; + let labels = self.topic_labels()?; + let mut merged_from: Vec<TopicRef> = merges + .keys() + .filter(|from| resolve_merge(from, &merges) == topic_id) + .map(|from| TopicRef { + label: labels.get(from).cloned().unwrap_or_default(), + topic_id: from.clone(), + }) + .collect(); + merged_from.sort_by(|a, b| a.topic_id.cmp(&b.topic_id)); + let annotations = self.annotations_by_note()?; + let mut notes: Vec<NoteView> = self + .rows()? + .iter() + .filter(|r| r.topic_id == topic_id) + .map(|r| note_view(r, &annotations)) + .collect(); + notes.sort_by(|a, b| a.created.cmp(&b.created)); + let sessions: HashSet<&str> = notes.iter().map(|n| n.source.session_id.as_str()).collect(); + Ok(Some(TopicDetail { + label, + created, + ask_count: notes.len(), + sessions: sessions.len(), + merged_from, + notes, + topic_id, + })) + } + + /// Every topic that has notes, most asked first. + pub fn topics(&self) -> Result<Vec<TopicCount>> { + Ok(topic_counts( + &self.rows()?, + &self.topic_labels()?, + &self.annotations_by_note()?, + )) + } + + /// Vault-relative path of the file that defines topic `id`. + pub fn topic_file(&self, id: &str) -> Result<Option<String>> { + Ok(self + .conn + .query_row("SELECT path FROM topic_current WHERE id = ?1", [id], |r| { + r.get(0) + }) + .optional()?) + } + + /// Vault-relative path of annotation `id` and the note it belongs to. + pub fn annotation_file(&self, id: &str) -> Result<Option<(String, String)>> { + Ok(self + .conn + .query_row( + "SELECT path, note_id FROM annotation_current WHERE id = ?1", + [id], + |r| Ok((r.get(0)?, r.get(1)?)), + ) + .optional()?) + } +} diff --git a/crates/distill-core/src/index/mod.rs b/crates/distill-core/src/index/mod.rs index 8f903e7..fef139a 100644 --- a/crates/distill-core/src/index/mod.rs +++ b/crates/distill-core/src/index/mod.rs @@ -4,6 +4,7 @@ //! web server), so it runs in WAL mode with a busy timeout, and every sync writes inside //! one immediate transaction. +mod browse; mod query; mod recall; mod scan; @@ -17,6 +18,7 @@ use rusqlite_migration::{M, Migrations}; use crate::error::{IoContext, Result}; +pub use browse::{NoteDetail, TopicDetail, TopicRef}; pub use query::{ AnnotationView, Conflict, InvalidFile, NoteHit, NoteView, ProjectCount, RecallResult, RecallTopic, Stats, TagCount, TopicCount, WeekCount, diff --git a/crates/distill-core/src/index/query.rs b/crates/distill-core/src/index/query.rs index f6ab366..2e32561 100644 --- a/crates/distill-core/src/index/query.rs +++ b/crates/distill-core/src/index/query.rs @@ -9,13 +9,15 @@ use crate::error::Result; use crate::model::{Agent, Source}; use crate::tags::resolve_alias; -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, ts_rs::TS)] +#[ts(export)] pub struct TagCount { pub tag: String, pub notes: usize, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, ts_rs::TS)] +#[ts(export)] pub struct AnnotationView { pub id: String, pub body: String, @@ -23,7 +25,8 @@ pub struct AnnotationView { pub updated: Option<String>, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, ts_rs::TS)] +#[ts(export)] pub struct NoteView { pub id: String, pub topic_id: String, @@ -37,7 +40,8 @@ pub struct NoteView { pub annotations: Vec<AnnotationView>, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, ts_rs::TS)] +#[ts(export)] pub struct NoteHit { pub id: String, pub topic_id: String, @@ -48,7 +52,8 @@ pub struct NoteHit { pub path: String, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, ts_rs::TS)] +#[ts(export)] pub struct RecallTopic { pub topic_id: String, pub label: String, @@ -57,14 +62,16 @@ pub struct RecallTopic { pub notes: Vec<NoteView>, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, ts_rs::TS)] +#[ts(export)] pub struct RecallResult { pub topics: Vec<RecallTopic>, /// Every tag in use, so the agent can reuse one instead of inventing a near-duplicate. pub tags: Vec<TagCount>, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, ts_rs::TS)] +#[ts(export)] pub struct TopicCount { pub topic_id: String, pub label: String, @@ -74,19 +81,22 @@ pub struct TopicCount { pub annotated: bool, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, ts_rs::TS)] +#[ts(export)] pub struct WeekCount { pub week: String, pub notes: usize, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, ts_rs::TS)] +#[ts(export)] pub struct ProjectCount { pub project: String, pub notes: usize, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, ts_rs::TS)] +#[ts(export)] pub struct Stats { pub notes: usize, pub topics: usize, @@ -100,14 +110,16 @@ pub struct Stats { pub invalid_files: usize, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, ts_rs::TS)] +#[ts(export)] pub struct Conflict { pub kind: String, pub id: String, pub paths: Vec<String>, } -#[derive(Debug, Clone, Serialize)] +#[derive(Debug, Clone, Serialize, ts_rs::TS)] +#[ts(export)] pub struct InvalidFile { pub path: String, pub error: String, @@ -116,17 +128,17 @@ pub struct InvalidFile { /// One row of `note_current`, with topic and tags already resolved through merges and /// aliases. #[derive(Debug, Clone)] -struct Row { - id: String, - path: String, - topic_id: String, - title: String, - question: String, - conclusion: String, - created: String, - created_utc: String, - source: Source, - tags: Vec<String>, +pub(super) struct Row { + pub id: String, + pub path: String, + pub topic_id: String, + pub title: String, + pub question: String, + pub conclusion: String, + pub created: String, + pub created_utc: String, + pub source: Source, + pub tags: Vec<String>, } const TOP_TOPICS: usize = 20; @@ -264,28 +276,8 @@ impl Index { pub fn stats(&self) -> Result<Stats> { let rows = self.rows()?; - let labels = self.topic_labels()?; let annotations = self.annotations_by_note()?; - let mut by_topic: BTreeMap<&str, Vec<&Row>> = BTreeMap::new(); - for row in &rows { - by_topic.entry(&row.topic_id).or_default().push(row); - } - let mut top: Vec<TopicCount> = by_topic - .iter() - .map(|(topic_id, notes)| TopicCount { - topic_id: topic_id.to_string(), - label: labels.get(*topic_id).cloned().unwrap_or_default(), - ask_count: notes.len(), - first_asked: min_created(notes, |a, b| a < b), - last_asked: min_created(notes, |a, b| a > b), - annotated: notes.iter().any(|n| annotations.contains_key(&n.id)), - }) - .collect(); - top.sort_by(|a, b| { - b.ask_count - .cmp(&a.ask_count) - .then(b.last_asked.cmp(&a.last_asked)) - }); + let mut top = topic_counts(&rows, &self.topic_labels()?, &annotations); let repeated_topics = top.iter().filter(|t| t.ask_count >= 2).count(); let topics = top.len(); top.truncate(TOP_TOPICS); @@ -360,7 +352,7 @@ impl Index { Ok(rows.collect::<rusqlite::Result<_>>()?) } - fn merges(&self) -> Result<HashMap<String, String>> { + pub(super) fn merges(&self) -> Result<HashMap<String, String>> { let mut stmt = self .conn .prepare("SELECT id, merged_into FROM topic_current WHERE merged_into IS NOT NULL")?; @@ -368,19 +360,19 @@ impl Index { Ok(rows.collect::<rusqlite::Result<_>>()?) } - fn topic_labels(&self) -> Result<HashMap<String, String>> { + pub(super) fn topic_labels(&self) -> Result<HashMap<String, String>> { let mut stmt = self.conn.prepare("SELECT id, label FROM topic_current")?; let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?; Ok(rows.collect::<rusqlite::Result<_>>()?) } - fn bodies(&self) -> Result<HashMap<String, String>> { + pub(super) fn bodies(&self) -> Result<HashMap<String, String>> { let mut stmt = self.conn.prepare("SELECT id, body FROM note_current")?; let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?; Ok(rows.collect::<rusqlite::Result<_>>()?) } - fn annotations_by_note(&self) -> Result<HashMap<String, Vec<AnnotationView>>> { + pub(super) fn annotations_by_note(&self) -> Result<HashMap<String, Vec<AnnotationView>>> { let mut stmt = self.conn.prepare( "SELECT note_id, id, body, created, updated FROM annotation_current ORDER BY created", )?; @@ -403,7 +395,7 @@ impl Index { Ok(out) } - fn rows(&self) -> Result<Vec<Row>> { + pub(super) fn rows(&self) -> Result<Vec<Row>> { let merges = self.merges()?; let aliases = self.aliases()?; let mut tags: HashMap<String, Vec<String>> = HashMap::new(); @@ -465,7 +457,7 @@ impl Index { } } -fn note_view(row: &Row, annotations: &HashMap<String, Vec<AnnotationView>>) -> NoteView { +pub(super) fn note_view(row: &Row, annotations: &HashMap<String, Vec<AnnotationView>>) -> NoteView { NoteView { id: row.id.clone(), topic_id: row.topic_id.clone(), @@ -480,7 +472,36 @@ fn note_view(row: &Row, annotations: &HashMap<String, Vec<AnnotationView>>) -> N } } -fn tag_counts(rows: &[Row]) -> Vec<TagCount> { +/// One entry per topic that has notes, most asked first, then most recently asked. +pub(super) fn topic_counts( + rows: &[Row], + labels: &HashMap<String, String>, + annotations: &HashMap<String, Vec<AnnotationView>>, +) -> Vec<TopicCount> { + let mut by_topic: BTreeMap<&str, Vec<&Row>> = BTreeMap::new(); + for row in rows { + by_topic.entry(&row.topic_id).or_default().push(row); + } + let mut out: Vec<TopicCount> = by_topic + .iter() + .map(|(topic_id, notes)| TopicCount { + topic_id: topic_id.to_string(), + label: labels.get(*topic_id).cloned().unwrap_or_default(), + ask_count: notes.len(), + first_asked: min_created(notes, |a, b| a < b), + last_asked: min_created(notes, |a, b| a > b), + annotated: notes.iter().any(|n| annotations.contains_key(&n.id)), + }) + .collect(); + out.sort_by(|a, b| { + b.ask_count + .cmp(&a.ask_count) + .then(b.last_asked.cmp(&a.last_asked)) + }); + out +} + +pub(super) fn tag_counts(rows: &[Row]) -> Vec<TagCount> { let mut counts: HashMap<&str, usize> = HashMap::new(); for tag in rows.iter().flat_map(|r| &r.tags) { *counts.entry(tag).or_default() += 1; @@ -506,7 +527,7 @@ fn min_created(notes: &[&Row], better: impl Fn(&str, &str) -> bool) -> String { best.map(|n| n.created.clone()).unwrap_or_default() } -fn resolve_merge(id: &str, merges: &HashMap<String, String>) -> String { +pub(super) fn resolve_merge(id: &str, merges: &HashMap<String, String>) -> String { let mut current = id.to_string(); let mut seen = HashSet::new(); while let Some(next) = merges.get(¤t) { diff --git a/crates/distill-core/src/lib.rs b/crates/distill-core/src/lib.rs index 589efa0..bac68de 100644 --- a/crates/distill-core/src/lib.rs +++ b/crates/distill-core/src/lib.rs @@ -2,6 +2,7 @@ //! index derived from it, and the operations every front end shares. pub mod config; +mod edit; pub mod error; mod fsutil; pub mod index; diff --git a/crates/distill-core/src/model.rs b/crates/distill-core/src/model.rs index c93e390..c911eda 100644 --- a/crates/distill-core/src/model.rs +++ b/crates/distill-core/src/model.rs @@ -7,9 +7,10 @@ use crate::error::{Error, Result}; pub const SCHEMA: u32 = 1; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ts_rs::TS)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] #[serde(rename_all = "kebab-case")] +#[ts(export)] pub enum Agent { Codex, ClaudeCode, @@ -36,8 +37,9 @@ impl Agent { /// Where a note came from. Copy the values from the `distill-source:` line the Distill hook /// injects: `distill-source: <agent> <session_id> <cwd>`. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ts_rs::TS)] #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))] +#[ts(export)] pub struct Source { pub agent: Agent, /// UUID of the agent session. @@ -46,6 +48,7 @@ pub struct Source { pub cwd: String, /// Git remote of the project, if known. #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] pub git_repo: Option<String>, } diff --git a/crates/distill-core/src/ops.rs b/crates/distill-core/src/ops.rs index 4dacff4..68c6fa4 100644 --- a/crates/distill-core/src/ops.rs +++ b/crates/distill-core/src/ops.rs @@ -96,6 +96,7 @@ pub fn init(dirs: &Dirs, opts: InitOptions) -> Result<InitReport> { ui: existing.as_ref().map_or_else( || UiConfig { port: pick_ui_port(), + ..UiConfig::default() }, |c| c.ui.clone(), ), diff --git a/crates/distill-core/src/sources.rs b/crates/distill-core/src/sources.rs index 989d95e..12d7d9d 100644 --- a/crates/distill-core/src/sources.rs +++ b/crates/distill-core/src/sources.rs @@ -252,7 +252,8 @@ fn has_rollout(dir: &Path, session_id: &str) -> bool { /// How to get back to the session a note came from: a desktop-app deep link and a /// terminal command that works without the app. Neither URL scheme is documented by its /// vendor; both were verified by hand on 2026-09-13 (spec D6). -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, ts_rs::TS)] +#[ts(export)] pub struct Reopen { pub link: String, pub command: String, diff --git a/crates/distill-core/src/vault.rs b/crates/distill-core/src/vault.rs index 5f98319..e5d2bcb 100644 --- a/crates/distill-core/src/vault.rs +++ b/crates/distill-core/src/vault.rs @@ -155,6 +155,12 @@ impl Vault { Ok(path) } + /// Rewrites a Markdown file this device owns (a topic it renames or merges, an + /// annotation it edits) in place. + pub fn rewrite_md<T: Serialize>(&self, path: &Path, meta: &T, body: &str) -> Result<()> { + write_atomic(path, render_md(path, meta, body)?.as_bytes()) + } + pub fn write_annotation(&self, meta: &AnnotationMeta, text: &str) -> Result<PathBuf> { let path = self.annotation_path(&meta.note, &meta.id); write_atomic(&path, render_md(&path, meta, text)?.as_bytes())?; diff --git a/crates/distill-core/tests/common/mod.rs b/crates/distill-core/tests/common/mod.rs new file mode 100644 index 0000000..78fc318 --- /dev/null +++ b/crates/distill-core/tests/common/mod.rs @@ -0,0 +1,54 @@ +//! Setup shared by the integration tests: a fresh config, data directory and vault. +#![allow(dead_code, reason = "each test binary uses a different subset")] + +use distill_core::Distill; +use distill_core::config::Dirs; +use distill_core::model::{Agent, Source}; +use distill_core::ops::{InitOptions, SaveRequest, init}; + +pub struct Setup { + pub dir: tempfile::TempDir, + pub dirs: Dirs, +} + +pub fn setup() -> Setup { + let dir = tempfile::tempdir().unwrap(); + let dirs = Dirs { + config_dir: dir.path().join("home/config"), + data_dir: dir.path().join("home/data"), + }; + init( + &dirs, + InitOptions { + vault: dir.path().join("vault"), + adopt: false, + bin_path: None, + }, + ) + .unwrap(); + Setup { dir, dirs } +} + +pub fn open(s: &Setup) -> Distill { + Distill::open_in(s.dirs.clone()).unwrap() +} + +pub fn request(topic: &str, tags: &[&str], new_tags: &[&str]) -> SaveRequest { + SaveRequest { + title: "bun:sqlite 在 macOS 上加载扩展".into(), + question: "为什么 bun:sqlite 加载不了 sqlite-vec?".into(), + conclusion: "macOS 系统 SQLite 禁止加载扩展。".into(), + key_points: vec!["换 rusqlite bundled".into()], + open_questions: vec![], + topic: topic.into(), + tags: tags.iter().map(|t| t.to_string()).collect(), + new_tags: new_tags.iter().map(|t| t.to_string()).collect(), + confirm_new: false, + source: Source { + agent: Agent::Codex, + session_id: "01a0d530-42ae-7731-8a1d-b4e07d9b837d".into(), + cwd: "/Users/me/project".into(), + git_repo: Some("github.com/me/project".into()), + }, + } +} diff --git a/crates/distill-core/tests/edit.rs b/crates/distill-core/tests/edit.rs new file mode 100644 index 0000000..0f0c96c --- /dev/null +++ b/crates/distill-core/tests/edit.rs @@ -0,0 +1,148 @@ +#![allow(clippy::unwrap_used)] // Test helpers panic on setup failure by design. + +mod common; + +use common::{open, request, setup}; +use distill_core::Error; + +#[test] +fn rename_rewrites_only_the_topic_file() { + let s = setup(); + let mut d = open(&s); + let saved = d.save(request("new", &[], &["sqlite"])).unwrap(); + let note_before = std::fs::read_to_string(&saved.path).unwrap(); + + d.rename_topic(&saved.topic_id, " 为什么 SQLite 扩展加载不了 ") + .unwrap(); + let topic = d.index.topic_detail(&saved.topic_id).unwrap().unwrap(); + assert_eq!(topic.label, "为什么 SQLite 扩展加载不了"); + assert_eq!(std::fs::read_to_string(&saved.path).unwrap(), note_before); + + assert!(matches!( + d.rename_topic(&saved.topic_id, "two\nlines"), + Err(Error::InvalidTopic { .. }) + )); + assert!(matches!( + d.rename_topic("01NOPE", "x"), + Err(Error::UnknownTopic { .. }) + )); +} + +#[test] +fn merge_moves_the_count_and_keeps_the_notes() { + let s = setup(); + let mut d = open(&s); + let a = d.save(request("new", &[], &["sqlite"])).unwrap(); + let b = d.save(request("new", &["sqlite"], &[])).unwrap(); + let c = d.save(request(&b.topic_id, &["sqlite"], &[])).unwrap(); + + let target = d.merge_topic(&a.topic_id, &b.topic_id).unwrap(); + assert_eq!(target, b.topic_id); + assert_eq!(d.index.ask_count(&a.topic_id).unwrap(), 3); + + // Asking for the merged-away topic lands on the one it was merged into. + let topic = d.index.topic_detail(&a.topic_id).unwrap().unwrap(); + assert_eq!(topic.topic_id, b.topic_id); + assert_eq!(topic.merged_from.len(), 1); + assert_eq!(topic.merged_from[0].topic_id, a.topic_id); + let ids: Vec<&str> = topic.notes.iter().map(|n| n.id.as_str()).collect(); + assert_eq!(ids, vec![&a.note_id, &b.note_id, &c.note_id]); + assert_eq!(topic.sessions, 1); + assert_eq!(d.index.topics().unwrap().len(), 1); + + match d.merge_topic(&b.topic_id, &a.topic_id) { + Err(Error::InvalidMerge { reason, .. }) => assert!(reason.contains("already")), + other => panic!("expected InvalidMerge, got {other:?}"), + } +} + +#[test] +fn annotations_can_be_edited_and_deleted() { + let s = setup(); + let mut d = open(&s); + let saved = d.save(request("new", &[], &["sqlite"])).unwrap(); + d.annotate(&saved.note_id, "第一版理解").unwrap(); + let id = d.index.note(&saved.note_id).unwrap().unwrap().annotations[0] + .id + .clone(); + + d.update_annotation(&id, "改过:token sk-proj-abcdefghijklmnopqrstuvwx 不该出现") + .unwrap(); + let a = &d.index.note(&saved.note_id).unwrap().unwrap().annotations[0]; + assert!(a.body.starts_with("改过")); + assert!(a.body.contains("[REDACTED:"), "{}", a.body); + assert!(a.updated.is_some()); + assert!(matches!( + d.update_annotation(&id, " "), + Err(Error::InvalidNote { .. }) + )); + + d.delete_annotation(&id).unwrap(); + let note = d.index.note(&saved.note_id).unwrap().unwrap(); + assert!(note.annotations.is_empty()); + assert!( + !s.dir + .path() + .join("vault/annotations") + .join(&saved.note_id) + .exists(), + "the empty per-note directory is removed" + ); + assert!(matches!( + d.delete_annotation(&id), + Err(Error::UnknownAnnotation { .. }) + )); +} + +#[test] +fn resolving_a_conflict_sets_the_other_copy_aside() { + let s = setup(); + let mut d = open(&s); + let saved = d.save(request("new", &[], &["sqlite"])).unwrap(); + let copy = saved + .path + .with_file_name(format!("{} (conflicted copy).md", saved.note_id)); + std::fs::copy(&saved.path, ©).unwrap(); + d.index.sync().unwrap(); + let conflict = d.index.conflicts().unwrap().remove(0); + assert_eq!(conflict.paths.len(), 2); + let keep = conflict + .paths + .iter() + .find(|p| p.contains("conflicted copy")) + .unwrap() + .clone(); + + assert!(matches!( + d.resolve_conflict("note", &saved.note_id, "notes/elsewhere.md"), + Err(Error::NotAConflictCopy { .. }) + )); + let moved = d.resolve_conflict("note", &saved.note_id, &keep).unwrap(); + assert_eq!(moved.len(), 1); + assert!(moved[0].starts_with(s.dirs.discarded_dir(d.vault.id()))); + assert!(moved[0].is_file()); + assert!(!saved.path.exists()); + assert!(copy.exists()); + assert!(d.index.conflicts().unwrap().is_empty()); + assert!(d.index.note(&saved.note_id).unwrap().is_some()); + + assert!(matches!( + d.resolve_conflict("note", &saved.note_id, &keep), + Err(Error::UnknownConflict { .. }) + )); +} + +#[test] +fn note_detail_has_the_page_contents() { + let s = setup(); + let mut d = open(&s); + let saved = d.save(request("new", &[], &["sqlite"])).unwrap(); + let detail = d.index.note_detail(&saved.note_id).unwrap().unwrap(); + assert!(detail.markdown.starts_with("# bun:sqlite")); + assert!(detail.markdown.contains("## 要点")); + assert_eq!(detail.ask_count, 1); + assert_eq!(detail.topic_label, "bun:sqlite 在 macOS 上加载扩展"); + assert_eq!(std::path::Path::new(&detail.file), saved.path); + assert!(detail.reopen.link.starts_with("codex://threads/")); + assert!(d.index.note_detail("01NOPE").unwrap().is_none()); +} diff --git a/crates/distill-core/tests/save.rs b/crates/distill-core/tests/save.rs index d9dd8b0..7bcc41a 100644 --- a/crates/distill-core/tests/save.rs +++ b/crates/distill-core/tests/save.rs @@ -1,56 +1,10 @@ #![allow(clippy::unwrap_used)] // Test helpers panic on setup failure by design. -use distill_core::config::Dirs; -use distill_core::model::{Agent, Source}; -use distill_core::ops::{InitOptions, SaveRequest, init}; -use distill_core::{Distill, Error}; +mod common; -struct Setup { - dir: tempfile::TempDir, - dirs: Dirs, -} - -fn setup() -> Setup { - let dir = tempfile::tempdir().unwrap(); - let dirs = Dirs { - config_dir: dir.path().join("home/config"), - data_dir: dir.path().join("home/data"), - }; - init( - &dirs, - InitOptions { - vault: dir.path().join("vault"), - adopt: false, - bin_path: None, - }, - ) - .unwrap(); - Setup { dir, dirs } -} - -fn open(s: &Setup) -> Distill { - Distill::open_in(s.dirs.clone()).unwrap() -} - -fn request(topic: &str, tags: &[&str], new_tags: &[&str]) -> SaveRequest { - SaveRequest { - title: "bun:sqlite 在 macOS 上加载扩展".into(), - question: "为什么 bun:sqlite 加载不了 sqlite-vec?".into(), - conclusion: "macOS 系统 SQLite 禁止加载扩展。".into(), - key_points: vec!["换 rusqlite bundled".into()], - open_questions: vec![], - topic: topic.into(), - tags: tags.iter().map(|t| t.to_string()).collect(), - new_tags: new_tags.iter().map(|t| t.to_string()).collect(), - confirm_new: false, - source: Source { - agent: Agent::Codex, - session_id: "01a0d530-42ae-7731-8a1d-b4e07d9b837d".into(), - cwd: "/Users/me/project".into(), - git_repo: Some("github.com/me/project".into()), - }, - } -} +use common::{open, request, setup}; +use distill_core::Error; +use distill_core::ops::{InitOptions, init}; #[test] fn second_save_on_same_topic_counts_as_a_repeat() { From 86d4fd5a9997f4d6d817be8cb9185298d4d98e66 Mon Sep 17 00:00:00 2001 From: Yanlin <jackey.dou@gmail.com> Date: Sat, 26 Sep 2026 12:27:48 -0700 Subject: [PATCH 2/7] Add the distill ui server A loopback-only HTTP server at http://distill.localhost:<ui.port>: session cookie from a one-time link, Host and Origin checks, a JSON API over the core browse and edit operations, server-sent change events from a vault watcher, and the embedded web app. distill mcp starts it in the background before it hands out note links; `ui.autostart = false` turns that off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- Cargo.lock | 758 +++++++++++++++++++++++- crates/distill-cli/CHANGELOG.md | 5 + crates/distill-cli/Cargo.toml | 12 +- crates/distill-cli/README.md | 11 +- crates/distill-cli/src/main.rs | 42 ++ crates/distill-cli/src/mcp.rs | 30 +- crates/distill-cli/src/ui/api.rs | 372 ++++++++++++ crates/distill-cli/src/ui/auth.rs | 166 ++++++ crates/distill-cli/src/ui/mod.rs | 218 +++++++ crates/distill-cli/src/ui/server.rs | 339 +++++++++++ crates/distill-cli/tests/mcp.rs | 41 ++ crates/distill-cli/tests/ui.rs | 254 ++++++++ docs/index.md | 1 + docs/plugin.md | 11 +- docs/web-ui.md | 92 +++ plugins/distill/CHANGELOG.md | 2 + plugins/distill/skills/distill/SKILL.md | 11 +- 17 files changed, 2347 insertions(+), 18 deletions(-) create mode 100644 crates/distill-cli/src/ui/api.rs create mode 100644 crates/distill-cli/src/ui/auth.rs create mode 100644 crates/distill-cli/src/ui/mod.rs create mode 100644 crates/distill-cli/src/ui/server.rs create mode 100644 crates/distill-cli/tests/ui.rs create mode 100644 docs/web-ui.md diff --git a/Cargo.lock b/Cargo.lock index 37e2ccf..a5bc02f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -56,7 +56,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -67,7 +67,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -97,12 +97,70 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + [[package]] name = "base64" version = "0.23.1" @@ -134,6 +192,15 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bstr" version = "1.13.1" @@ -151,6 +218,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.12.1" @@ -242,6 +315,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -263,6 +342,15 @@ dependencies = [ "libc", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "darling" version = "0.24.1" @@ -334,6 +422,17 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + [[package]] name = "directories" version = "6.0.0" @@ -343,6 +442,15 @@ dependencies = [ "dirs-sys", ] +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + [[package]] name = "dirs-sys" version = "0.5.0" @@ -352,7 +460,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -361,13 +469,23 @@ version = "0.0.1" dependencies = [ "anyhow", "assert_cmd", + "axum", + "blake3", "clap", "distill-core", + "getrandom", + "listeners", + "notify-debouncer-mini", + "open", "rmcp", + "rust-embed", "serde", "serde_json", "tempfile", "tokio", + "tokio-stream", + "ts-rs", + "ureq", "walkdir", ] @@ -388,6 +506,7 @@ dependencies = [ "tempfile", "thiserror", "toml", + "ts-rs", "ulid", "walkdir", "yaml_serde", @@ -412,7 +531,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -445,6 +564,24 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futures" version = "0.3.34" @@ -578,6 +715,95 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + [[package]] name = "iana-time-zone" version = "0.1.65" @@ -620,6 +846,45 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inotify" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" +dependencies = [ + "bitflags 2.13.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -697,6 +962,26 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kqueue" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.2", + "libc", +] + [[package]] name = "libc" version = "0.2.189" @@ -735,18 +1020,104 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "listeners" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "504c4ecfa20498c07ae4ff19b632d212e1fdcef83195b8e9eb2552306c42205f" +dependencies = [ + "byteorder", + "cc", + "libc", + "rustix", + "windows", +] + [[package]] name = "log" version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags 2.13.2", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-debouncer-mini" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17849edfaabd9a5fef1c606d99cfc615a8e99f7ac4366406d86c7942a3184cf2" +dependencies = [ + "log", + "notify", + "notify-types", + "tempfile", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags 2.13.2", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -768,6 +1139,16 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "open" +version = "5.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa576c76302b7b808eecc68061e67336c47833ef9d22caa74dda10fa9675eebc" +dependencies = [ + "is-wsl", + "libc", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -780,6 +1161,12 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1005,6 +1392,43 @@ dependencies = [ "rusqlite", ] +[[package]] +name = "rust-embed" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097" +dependencies = [ + "mime_guess", + "proc-macro2", + "quote", + "rust-embed-utils", + "shellexpand", + "syn 2.0.119", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0" +dependencies = [ + "mime_guess", + "sha2", + "walkdir", +] + [[package]] name = "rustix" version = "1.1.5" @@ -1015,7 +1439,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1119,6 +1543,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_spanned" version = "1.1.1" @@ -1128,12 +1563,54 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shellexpand" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" +dependencies = [ + "dirs", +] + [[package]] name = "shlex" version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + [[package]] name = "slab" version = "0.4.12" @@ -1146,6 +1623,16 @@ version = "1.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "sqlite-wasm-rs" version = "0.5.5" @@ -1186,6 +1673,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + [[package]] name = "tempfile" version = "3.27.0" @@ -1196,7 +1689,16 @@ dependencies = [ "getrandom", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", ] [[package]] @@ -1232,8 +1734,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", + "libc", + "mio", "pin-project-lite", + "signal-hook-registry", + "socket2", "tokio-macros", + "windows-sys 0.61.2", ] [[package]] @@ -1247,6 +1754,18 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + [[package]] name = "tokio-util" version = "0.7.19" @@ -1300,12 +1819,41 @@ version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -1331,6 +1879,34 @@ dependencies = [ "once_cell", ] +[[package]] +name = "ts-rs" +version = "12.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756050066659291d47a554a9f558125db17428b073c5ffce1daf5dcb0f7231d8" +dependencies = [ + "thiserror", + "ts-rs-macros", +] + +[[package]] +name = "ts-rs-macros" +version = "12.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d90eea51bc7988ef9e674bf80a85ba6804739e535e9cab48e4bb34a8b652aa" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "termcolor", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "ulid" version = "3.0.0" @@ -1341,12 +1917,49 @@ dependencies = [ "web-time", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "ureq" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +dependencies = [ + "base64", + "log", + "percent-encoding", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1389,6 +2002,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasm-bindgen" version = "0.2.128" @@ -1450,7 +2069,28 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", ] [[package]] @@ -1466,6 +2106,17 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -1494,6 +2145,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -1512,6 +2173,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1521,6 +2191,80 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "1.0.4" diff --git a/crates/distill-cli/CHANGELOG.md b/crates/distill-cli/CHANGELOG.md index d812585..c5e987d 100644 --- a/crates/distill-cli/CHANGELOG.md +++ b/crates/distill-cli/CHANGELOG.md @@ -10,3 +10,8 @@ - `distill mcp`: stdio MCP server with `distill_recall`, `distill_save`, `distill_search` and `distill_stats`. - `distill hook user-prompt-submit`: prints the context the plugin injects on every prompt. +- `distill ui`: local web UI server at `http://distill.localhost:<ui.port>` with a session + cookie from a one-time link, loopback-only listening, Host and Origin checks, a JSON API and + server-sent change events. `distill ui stop` stops it. See `docs/web-ui.md`. +- `distill mcp` starts the web UI in the background before returning note links, unless + `ui.autostart` is `false`. `distill_recall` and `distill_search` notes now carry `url`. diff --git a/crates/distill-cli/Cargo.toml b/crates/distill-cli/Cargo.toml index bc0150e..3645bc3 100644 --- a/crates/distill-cli/Cargo.toml +++ b/crates/distill-cli/Cargo.toml @@ -11,12 +11,22 @@ path = "src/main.rs" [dependencies] anyhow = "1.0.104" +axum = "0.8.9" +blake3 = "1.8.7" clap = { version = "4.6.7", features = ["derive"] } distill-core = { version = "0.0.1", path = "../distill-core", features = ["schema"] } +getrandom = "0.4.3" +listeners = "0.6.1" +notify-debouncer-mini = "0.7.0" +open = "5.4.4" rmcp = { version = "3.4.1", features = ["server", "transport-io", "macros"] } +rust-embed = { version = "8.12.0", features = ["mime-guess", "interpolate-folder-path"] } serde = "1.0.229" serde_json = "1.0.151" -tokio = { version = "1.53.1", features = ["rt", "macros", "io-std"] } +tokio = { version = "1.53.1", features = ["rt", "macros", "io-std", "rt-multi-thread", "net", "signal", "sync", "time"] } +tokio-stream = { version = "0.1.19", features = ["sync"] } +ts-rs = "12.0.1" +ureq = { version = "3.4.2", default-features = false } [lints] workspace = true diff --git a/crates/distill-cli/README.md b/crates/distill-cli/README.md index dc2d35e..d4611dc 100644 --- a/crates/distill-cli/README.md +++ b/crates/distill-cli/README.md @@ -6,10 +6,14 @@ to stderr with a non-zero exit code. ## Install ```bash -cargo install --path crates/distill-cli +mise run install distill init ``` +`mise run install` builds the web UI (`apps/web`, needs Node and pnpm) and then runs +`cargo install --path crates/distill-cli`, which embeds it. A binary built without +`apps/web/dist` works, but `distill ui` shows a page asking you to rebuild. + `distill init` proposes `iCloud Drive/Distill` on macOS when iCloud Drive is on, otherwise `Distill` in your documents folder. Pass `--vault <dir>` to choose; it accepts `~/…` and, on macOS, `icloud:<path>`. Point a second device at the synced directory and `init` joins the @@ -28,8 +32,10 @@ existing vault. | `annotate <note-id> <text…>` | Add your own understanding to a note | | `reindex` | Rebuild this device's index from the vault | | `vault show \| use <dir> \| move <dir>` | Show, switch, or copy-and-switch the vault | -| `config list \| get <key> \| set <key> <value>` | Settable keys: `ui.port`, `suggest.enabled` | +| `config list \| get <key> \| set <key> <value>` | Settable keys: `ui.port`, `ui.autostart`, `suggest.enabled` | | `doctor` | Paths, plugin install, conflicts, unreadable files; exits non-zero when something needs attention | +| `ui [--no-open] [--port <port>]` | Start the web UI and open an authorized browser tab ([docs/web-ui.md](../../docs/web-ui.md)) | +| `ui stop` | Stop the running web UI | | `mcp` | Stdio MCP server for the plugin ([docs/plugin.md](../../docs/plugin.md)) | | `hook user-prompt-submit` | Hook entry point for the plugin; always exits 0 | @@ -42,5 +48,6 @@ The `SaveRequest` fields and tag rules are in the |---|---| | `DISTILL_HOME` | Use `$DISTILL_HOME/config` and `$DISTILL_HOME/data` instead of the platform directories | | `DISTILL_VAULT` | Use this vault instead of the configured one | +| `PORT` | `distill ui` serves on this port instead of `ui.port` | | `CODEX_HOME` | Codex state directory (default `~/.codex`), for source checks and `doctor` | | `CLAUDE_CONFIG_DIR` | Claude Code state directory (default `~/.claude`), for `doctor` | diff --git a/crates/distill-cli/src/main.rs b/crates/distill-cli/src/main.rs index d308453..1647101 100644 --- a/crates/distill-cli/src/main.rs +++ b/crates/distill-cli/src/main.rs @@ -1,6 +1,7 @@ mod hook; mod mcp; mod render; +mod ui; use std::io::{IsTerminal, Read, Write}; use std::path::PathBuf; @@ -84,6 +85,8 @@ enum Command { Config(ConfigCommand), /// Check the vault, index and setup, and list what needs attention. Doctor, + /// Open the web UI in a browser, starting its local server if needed. + Ui(UiArgs), /// Run the MCP server on stdio. Started by the Distill plugin, one per agent session. Mcp, /// Hook entry points. Run by the Distill plugin; always exit 0. @@ -91,6 +94,28 @@ enum Command { Hook(HookEvent), } +#[derive(clap::Args)] +#[command(args_conflicts_with_subcommands = true)] +struct UiArgs { + #[command(subcommand)] + command: Option<UiCommand>, + /// Serve on this port instead of `ui.port`. Links agents hand out use `ui.port`. + #[arg(long)] + port: Option<u16>, + /// Print the authorization link instead of opening a browser. + #[arg(long)] + no_open: bool, + /// Serve without a terminal. Used when `distill mcp` starts the server. + #[arg(long, hide = true)] + background: bool, +} + +#[derive(Subcommand)] +enum UiCommand { + /// Stop the running web UI server. + Stop, +} + #[derive(Subcommand)] enum HookEvent { /// Reads the hook JSON on stdin and prints the context to inject. @@ -187,6 +212,23 @@ fn run(cli: Cli) -> Result<()> { } Command::Vault(cmd) => vault(cmd, json), Command::Config(cmd) => config(cmd, json), + Command::Ui(args) => match args.command { + Some(UiCommand::Stop) => { + let stopped = ui::stop()?; + emit(json, &serde_json::json!({ "stopped": stopped }), |_| { + if stopped { + "Stopped the Distill web UI.".into() + } else { + "The Distill web UI was not running.".into() + } + }) + } + None => ui::run(ui::Options { + port: args.port, + background: args.background, + open_browser: !args.no_open, + }), + }, Command::Mcp => tokio::runtime::Builder::new_current_thread() .enable_all() .build()? diff --git a/crates/distill-cli/src/mcp.rs b/crates/distill-cli/src/mcp.rs index 49a93f3..fedef92 100644 --- a/crates/distill-cli/src/mcp.rs +++ b/crates/distill-cli/src/mcp.rs @@ -70,6 +70,11 @@ impl DistillServer { respond(|| { let d = Distill::open()?; let result = d.index.recall(&p.question, p.limit.unwrap_or(5))?; + let warnings = if result.topics.is_empty() { + Vec::new() + } else { + start_ui(&d) + }; let topics: Vec<Value> = result .topics .iter() @@ -82,7 +87,7 @@ impl DistillServer { }) }) .collect(); - Ok(json!({ "topics": topics, "tags": result.tags })) + Ok(json!({ "topics": topics, "tags": result.tags, "warnings": warnings })) }) } @@ -99,7 +104,10 @@ impl DistillServer { for w in &warnings { eprintln!("distill: {w}"); } - let saved = Distill::open()?.save(req)?; + let mut warnings = warnings; + let mut d = Distill::open()?; + let saved = d.save(req)?; + warnings.extend(start_ui(&d)); let mut out = serde_json::to_value(&saved).map_err(json_error)?; out["file"] = json!(saved.path); out["warnings"] = json!(warnings); @@ -127,6 +135,7 @@ impl DistillServer { .map(|h| { let mut v = serde_json::to_value(h).map_err(json_error)?; v["file"] = json!(abs(&d, &h.path)); + v["url"] = json!(d.config.note_url(&h.id)); Ok(v) }) .collect::<distill_core::Result<Vec<Value>>>()?; @@ -167,9 +176,26 @@ pub async fn serve() -> anyhow::Result<()> { Ok(()) } +/// Starts the web UI so the links in a tool result open. A failure does not fail the tool +/// call; it comes back as a warning the agent can pass on. +fn start_ui(d: &Distill) -> Vec<String> { + if !d.config.ui.autostart { + return Vec::new(); + } + match crate::ui::ensure_running(&d.config, &d.dirs) { + Ok(_) => Vec::new(), + Err(e) => { + let warning = format!("note links may not open: {e:#}"); + eprintln!("distill: {warning}"); + vec![warning] + } + } +} + fn note_json(d: &Distill, n: &NoteView) -> Value { json!({ "id": n.id, + "url": d.config.note_url(&n.id), "title": n.title, "question": n.question, "conclusion": n.conclusion, diff --git a/crates/distill-cli/src/ui/api.rs b/crates/distill-cli/src/ui/api.rs new file mode 100644 index 0000000..90b07e5 --- /dev/null +++ b/crates/distill-cli/src/ui/api.rs @@ -0,0 +1,372 @@ +//! The JSON API behind the web UI. Request and response types are defined here and in +//! `distill-core`; the UI's TypeScript types are generated from them by ts-rs. +//! +//! Every handler opens config, vault and index afresh on a blocking thread, the same way +//! `distill mcp` does, so the server always sees the current vault and settings. + +use axum::extract::{Path, Query, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post, put}; +use axum::{Json, Router}; +use distill_core::Distill; +use distill_core::index::{ + Conflict, InvalidFile, NoteDetail, NoteHit, RecallResult, Stats, TagCount, TopicCount, + TopicDetail, +}; +use serde::{Deserialize, Serialize}; + +use super::server::AppState; + +#[derive(Debug, Serialize, Deserialize, ts_rs::TS)] +#[ts(export)] +pub struct Health { + /// Always `"distill"`: tells a Distill server apart from another program on the port. + pub app: String, + pub version: String, + pub pid: u32, +} + +#[derive(Debug, Serialize, ts_rs::TS)] +#[ts(export)] +pub struct Session { + pub version: String, + pub vault: String, + /// `http://distill.localhost:<port>`, the address links point at. + pub origin: String, +} + +#[derive(Debug, Deserialize, ts_rs::TS)] +#[ts(export)] +pub struct NotesQuery { + /// Keywords; empty lists the most recent notes. + #[ts(optional)] + pub q: Option<String>, + #[ts(optional)] + pub tag: Option<String>, + #[ts(optional)] + pub limit: Option<usize>, +} + +#[derive(Debug, Deserialize, ts_rs::TS)] +#[ts(export)] +pub struct RecallQuery { + pub q: String, + #[ts(optional)] + pub limit: Option<usize>, +} + +#[derive(Debug, Deserialize, ts_rs::TS)] +#[ts(export)] +pub struct AnnotationInput { + pub text: String, +} + +#[derive(Debug, Deserialize, ts_rs::TS)] +#[ts(export)] +pub struct TopicRename { + pub label: String, +} + +#[derive(Debug, Deserialize, ts_rs::TS)] +#[ts(export)] +pub struct TopicMerge { + /// The topic to merge into. The topic in the URL is the one that goes away. + pub into: String, +} + +#[derive(Debug, Serialize, ts_rs::TS)] +#[ts(export)] +pub struct Merged { + /// The topic both now resolve to. + pub topic_id: String, +} + +#[derive(Debug, Deserialize, ts_rs::TS)] +#[ts(export)] +pub struct ConflictChoice { + /// `note`, `topic` or `annotation`. + pub kind: String, + pub id: String, + /// Vault-relative path of the copy to keep. + pub keep: String, +} + +#[derive(Debug, Serialize, ts_rs::TS)] +#[ts(export)] +pub struct Problems { + pub conflicts: Vec<Conflict>, + pub invalid_files: Vec<InvalidFile>, +} + +#[derive(Debug, Serialize, ts_rs::TS)] +#[ts(export)] +pub struct ApiErrorBody { + pub error: String, +} + +const DEFAULT_LIMIT: usize = 50; +const MAX_LIMIT: usize = 500; + +/// Routes under `/api`, all behind the session check in `server.rs`. +pub fn routes() -> Router<AppState> { + Router::new() + .route("/session", get(session)) + .route("/notes", get(notes)) + .route("/notes/{id}", get(note)) + .route("/notes/{id}/annotations", post(add_annotation)) + .route( + "/annotations/{id}", + put(update_annotation).delete(delete_annotation), + ) + .route("/topics", get(topics)) + .route("/topics/{id}", get(topic).put(rename_topic)) + .route("/topics/{id}/merge", post(merge_topic)) + .route("/tags", get(tags)) + .route("/stats", get(stats)) + .route("/recall", get(recall)) + .route("/problems", get(problems)) + .route("/conflicts/resolve", post(resolve_conflict)) +} + +pub fn health() -> Health { + Health { + app: "distill".into(), + version: env!("CARGO_PKG_VERSION").into(), + pid: std::process::id(), + } +} + +async fn session(State(s): State<AppState>) -> Result<Json<Session>, ApiError> { + read(&s, |d| { + Ok(Session { + version: env!("CARGO_PKG_VERSION").into(), + vault: d.vault.root().display().to_string(), + origin: d.config.ui_origin(), + }) + }) + .await +} + +async fn notes( + State(s): State<AppState>, + Query(q): Query<NotesQuery>, +) -> Result<Json<Vec<NoteHit>>, ApiError> { + let limit = q.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT); + read(&s, move |d| { + d.index + .search(q.q.as_deref().unwrap_or_default(), q.tag.as_deref(), limit) + }) + .await +} + +async fn note( + State(s): State<AppState>, + Path(id): Path<String>, +) -> Result<Json<NoteDetail>, ApiError> { + read(&s, move |d| { + d.index + .note_detail(&id)? + .ok_or(distill_core::Error::UnknownNote { id }) + }) + .await +} + +async fn add_annotation( + State(s): State<AppState>, + Path(id): Path<String>, + Json(body): Json<AnnotationInput>, +) -> Result<Json<NoteDetail>, ApiError> { + let detail = write(&s, move |d| { + d.annotate(&id, &body.text)?; + d.index + .note_detail(&id)? + .ok_or(distill_core::Error::UnknownNote { id }) + }) + .await?; + Ok(Json(detail)) +} + +async fn update_annotation( + State(s): State<AppState>, + Path(id): Path<String>, + Json(body): Json<AnnotationInput>, +) -> Result<StatusCode, ApiError> { + write(&s, move |d| d.update_annotation(&id, &body.text)).await?; + Ok(StatusCode::NO_CONTENT) +} + +async fn delete_annotation( + State(s): State<AppState>, + Path(id): Path<String>, +) -> Result<StatusCode, ApiError> { + write(&s, move |d| d.delete_annotation(&id)).await?; + Ok(StatusCode::NO_CONTENT) +} + +async fn topics(State(s): State<AppState>) -> Result<Json<Vec<TopicCount>>, ApiError> { + read(&s, |d| d.index.topics()).await +} + +async fn topic( + State(s): State<AppState>, + Path(id): Path<String>, +) -> Result<Json<TopicDetail>, ApiError> { + read(&s, move |d| { + d.index + .topic_detail(&id)? + .ok_or(distill_core::Error::UnknownTopic { id }) + }) + .await +} + +async fn rename_topic( + State(s): State<AppState>, + Path(id): Path<String>, + Json(body): Json<TopicRename>, +) -> Result<StatusCode, ApiError> { + write(&s, move |d| d.rename_topic(&id, &body.label)).await?; + Ok(StatusCode::NO_CONTENT) +} + +async fn merge_topic( + State(s): State<AppState>, + Path(id): Path<String>, + Json(body): Json<TopicMerge>, +) -> Result<Json<Merged>, ApiError> { + let merged = write(&s, move |d| { + Ok(Merged { + topic_id: d.merge_topic(&id, &body.into)?, + }) + }) + .await?; + Ok(Json(merged)) +} + +async fn tags(State(s): State<AppState>) -> Result<Json<Vec<TagCount>>, ApiError> { + read(&s, |d| d.index.tags()).await +} + +async fn stats(State(s): State<AppState>) -> Result<Json<Stats>, ApiError> { + read(&s, |d| d.index.stats()).await +} + +async fn recall( + State(s): State<AppState>, + Query(q): Query<RecallQuery>, +) -> Result<Json<RecallResult>, ApiError> { + read(&s, move |d| d.index.recall(&q.q, q.limit.unwrap_or(5))).await +} + +async fn problems(State(s): State<AppState>) -> Result<Json<Problems>, ApiError> { + read(&s, |d| { + Ok(Problems { + conflicts: d.index.conflicts()?, + invalid_files: d.index.invalid_files()?, + }) + }) + .await +} + +async fn resolve_conflict( + State(s): State<AppState>, + Json(body): Json<ConflictChoice>, +) -> Result<StatusCode, ApiError> { + write(&s, move |d| { + d.resolve_conflict(&body.kind, &body.id, &body.keep) + .map(drop) + }) + .await?; + Ok(StatusCode::NO_CONTENT) +} + +async fn read<T, F>(state: &AppState, f: F) -> Result<Json<T>, ApiError> +where + T: Send + 'static, + F: FnOnce(&mut Distill) -> distill_core::Result<T> + Send + 'static, +{ + Ok(Json(run(state, f).await?)) +} + +/// Like `read`, then tells open pages that the vault changed. +async fn write<T, F>(state: &AppState, f: F) -> Result<T, ApiError> +where + T: Send + 'static, + F: FnOnce(&mut Distill) -> distill_core::Result<T> + Send + 'static, +{ + let out = run(state, f).await?; + state.notify_changed(); + Ok(out) +} + +async fn run<T, F>(state: &AppState, f: F) -> Result<T, ApiError> +where + T: Send + 'static, + F: FnOnce(&mut Distill) -> distill_core::Result<T> + Send + 'static, +{ + let dirs = state.dirs.clone(); + Ok(tokio::task::spawn_blocking(move || { + let mut d = Distill::open_in(dirs)?; + f(&mut d) + }) + .await + .map_err(|e| ApiError::internal(format!("request task failed: {e}")))??) +} + +#[derive(Debug)] +pub struct ApiError { + status: StatusCode, + message: String, +} + +impl ApiError { + pub fn new(status: StatusCode, message: impl Into<String>) -> Self { + Self { + status, + message: message.into(), + } + } + + fn internal(message: String) -> Self { + Self::new(StatusCode::INTERNAL_SERVER_ERROR, message) + } +} + +impl From<distill_core::Error> for ApiError { + fn from(err: distill_core::Error) -> Self { + use distill_core::Error as E; + let status = match &err { + E::UnknownNote { .. } + | E::UnknownTopic { .. } + | E::UnknownAnnotation { .. } + | E::UnknownConflict { .. } => StatusCode::NOT_FOUND, + E::InvalidTopic { .. } + | E::InvalidMerge { .. } + | E::NotAConflictCopy { .. } + | E::InvalidNote { .. } => StatusCode::BAD_REQUEST, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }; + let mut message = err.to_string(); + let mut source = std::error::Error::source(&err); + while let Some(cause) = source { + message.push_str(&format!(": {cause}")); + source = cause.source(); + } + if status == StatusCode::INTERNAL_SERVER_ERROR { + eprintln!("distill ui: {message}"); + } + Self { status, message } + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + ( + self.status, + Json(ApiErrorBody { + error: self.message, + }), + ) + .into_response() + } +} diff --git a/crates/distill-cli/src/ui/auth.rs b/crates/distill-cli/src/ui/auth.rs new file mode 100644 index 0000000..a2eebdd --- /dev/null +++ b/crates/distill-cli/src/ui/auth.rs @@ -0,0 +1,166 @@ +//! Who may call the web UI's API (spec D7). Any page the user has open can send requests to +//! a loopback port, so every API call needs the session secret: the browser holds it as a +//! cookie, the CLI sends it as a bearer token. A browser gets the cookie by redeeming a +//! one-time token that `distill ui` puts in the URL it opens. +//! +//! The secret and pending tokens live in `<data dir>/ui/`, readable only by the user, so +//! whichever process started the server, `distill ui` in a terminal can authorize a browser. + +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::time::{Duration, SystemTime}; + +use anyhow::{Context, Result}; + +pub const COOKIE: &str = "distill_session"; +/// A token in a URL that was never opened stops working after this long. +const TOKEN_TTL: Duration = Duration::from_secs(5 * 60); +/// Browsers cap cookie lifetime at 400 days. +const COOKIE_MAX_AGE_SECS: u64 = 400 * 24 * 60 * 60; + +#[derive(Clone)] +pub struct Auth { + secret: String, + tokens_dir: PathBuf, +} + +impl Auth { + /// Reads the secret in `dir`, creating it on first use. Two processes starting at once + /// both end up with the same secret: it is created with a hard link, which fails when + /// the file already exists. + pub fn load_or_create(dir: &Path) -> Result<Self> { + std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?; + let path = dir.join("secret"); + if !path.exists() { + let tmp = dir.join(format!(".secret-{}", std::process::id())); + write_private(&tmp, random_hex()?.as_bytes())?; + let linked = std::fs::hard_link(&tmp, &path); + std::fs::remove_file(&tmp).with_context(|| format!("removing {}", tmp.display()))?; + match linked { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(e) => { + return Err(e).with_context(|| format!("creating {}", path.display())); + } + } + } + let secret = std::fs::read_to_string(&path) + .with_context(|| format!("reading the UI secret {}", path.display()))? + .trim() + .to_string(); + anyhow::ensure!( + secret.len() >= 32, + "the UI secret {} is too short; delete it and run `distill ui` again", + path.display() + ); + Ok(Self { + secret, + tokens_dir: dir.join("tokens"), + }) + } + + pub fn secret(&self) -> &str { + &self.secret + } + + /// Compared through BLAKE3 hashes, whose equality check runs in constant time. + pub fn check(&self, candidate: &str) -> bool { + blake3::hash(candidate.as_bytes()) == blake3::hash(self.secret.as_bytes()) + } + + /// A token for one browser to redeem at `/auth` within five minutes. + pub fn issue_token(&self) -> Result<String> { + let token = random_hex()?; + write_private(&self.token_file(&token), b"")?; + Ok(token) + } + + /// True once per issued token, and only while it is fresh. + pub fn redeem(&self, token: &str) -> Result<bool> { + if token.len() != 64 || !token.chars().all(|c| c.is_ascii_hexdigit()) { + return Ok(false); + } + let path = self.token_file(token); + let Ok(meta) = std::fs::metadata(&path) else { + return Ok(false); + }; + std::fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?; + let age = meta + .modified() + .ok() + .and_then(|m| SystemTime::now().duration_since(m).ok()); + Ok(age.is_some_and(|a| a < TOKEN_TTL)) + } + + pub fn set_cookie(&self) -> String { + format!( + "{COOKIE}={}; Path=/; HttpOnly; SameSite=Strict; Max-Age={COOKIE_MAX_AGE_SECS}", + self.secret + ) + } + + /// Token files are named by the token's hash, so listing the directory reveals nothing + /// that can be redeemed. + fn token_file(&self, token: &str) -> PathBuf { + self.tokens_dir + .join(blake3::hash(token.as_bytes()).to_hex().as_str()) + } +} + +/// The value of `name` in a `Cookie` header. +pub fn cookie_value<'a>(header: &'a str, name: &str) -> Option<&'a str> { + header.split(';').find_map(|pair| { + let (k, v) = pair.trim().split_once('=')?; + (k == name).then_some(v) + }) +} + +fn random_hex() -> Result<String> { + let mut bytes = [0u8; 32]; + getrandom::fill(&mut bytes).map_err(|e| anyhow::anyhow!("no system randomness: {e}"))?; + Ok(bytes.iter().map(|b| format!("{b:02x}")).collect()) +} + +fn write_private(path: &Path, bytes: &[u8]) -> Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?; + } + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create(true).truncate(true); + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut opts, 0o600); + let mut file = opts + .open(path) + .with_context(|| format!("creating {}", path.display()))?; + file.write_all(bytes) + .with_context(|| format!("writing {}", path.display())) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::*; + + #[test] + fn secret_is_shared_and_tokens_redeem_once() { + let dir = tempfile::tempdir().unwrap(); + let a = Auth::load_or_create(dir.path()).unwrap(); + let b = Auth::load_or_create(dir.path()).unwrap(); + assert_eq!(a.secret(), b.secret()); + assert!(a.check(b.secret())); + assert!(!a.check("guess")); + + let token = a.issue_token().unwrap(); + assert!(b.redeem(&token).unwrap()); + assert!(!b.redeem(&token).unwrap()); + assert!(!b.redeem("../secret").unwrap()); + } + + #[test] + fn reads_cookie_values() { + let header = "theme=dark; distill_session=abc; other=1"; + assert_eq!(cookie_value(header, COOKIE), Some("abc")); + assert_eq!(cookie_value("x=1", COOKIE), None); + } +} diff --git a/crates/distill-cli/src/ui/mod.rs b/crates/distill-cli/src/ui/mod.rs new file mode 100644 index 0000000..a131a93 --- /dev/null +++ b/crates/distill-cli/src/ui/mod.rs @@ -0,0 +1,218 @@ +//! `distill ui`: the local web UI (spec D7). One server per device, at the fixed address +//! `http://distill.localhost:<ui.port>`. It starts when the user runs `distill ui`, or in +//! the background when `distill mcp` hands an agent a link, and runs until `distill ui stop` +//! or logout. It is never registered as a system service. +//! +//! The health endpoint doubles as the single-instance lock: before binding, a process asks +//! the port whether Distill is already there. + +mod api; +mod auth; +mod server; + +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use anyhow::{Context, Result, bail}; +use distill_core::Distill; +use distill_core::config::{Dirs, LocalConfig}; + +use self::api::Health; +use self::auth::Auth; + +/// How long `ensure_running` waits for a background server to answer. +const START_TIMEOUT: Duration = Duration::from_secs(5); + +pub struct Options { + pub port: Option<u16>, + pub background: bool, + pub open_browser: bool, +} + +/// Runs the server in the foreground, or, when one is already running, authorizes a +/// browser for it and returns. +pub fn run(opts: Options) -> Result<()> { + let dirs = Dirs::discover()?; + let d = Distill::open_in(dirs.clone())?; + let port = match (opts.port, std::env::var("PORT")) { + (Some(port), _) => port, + (None, Ok(env)) => env + .parse() + .with_context(|| format!("PORT={env} is not a port number"))?, + (None, Err(_)) => d.config.ui.port, + }; + let origin = origin(port); + + if probe(port).is_some() { + if !opts.background { + authorize_browser(&dirs, &origin, opts.open_browser)?; + } + return Ok(()); + } + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + let bound = match runtime.block_on(server::bind(port)) { + Ok(bound) => bound, + Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => { + // Another `distill ui` may have won the race to bind. + if wait_for_health(port, Duration::from_millis(500)) { + if !opts.background { + authorize_browser(&dirs, &origin, opts.open_browser)?; + } + return Ok(()); + } + bail!("{}", port_taken(port)); + } + Err(e) => return Err(e).with_context(|| format!("listening on 127.0.0.1:{port}")), + }; + if !opts.background { + println!("Distill is running at {origin} (Ctrl-C to stop)"); + authorize_browser(&dirs, &origin, opts.open_browser)?; + } + let vault = d.vault.root().to_path_buf(); + drop(d); + runtime.block_on(server::serve(bound, dirs, &vault, port)) +} + +/// `distill ui stop`. Returns false when no server was running. +pub fn stop() -> Result<bool> { + let dirs = Dirs::discover()?; + let port = LocalConfig::load(&dirs)?.ui.port; + if probe(port).is_none() { + return Ok(false); + } + let auth = Auth::load_or_create(&dirs.data_dir.join("ui"))?; + let res = agent() + .post(format!("http://127.0.0.1:{port}/api/shutdown")) + .header("Host", distill_core::config::UI_HOST) + .header("Authorization", format!("Bearer {}", auth.secret())) + .send_empty() + .with_context(|| format!("asking the server on port {port} to stop"))?; + anyhow::ensure!( + res.status().is_success(), + "the server on port {port} refused to stop: HTTP {}", + res.status() + ); + let deadline = Instant::now() + START_TIMEOUT; + while probe(port).is_some() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(100)); + } + Ok(true) +} + +/// Makes sure a server answers on the configured port, starting one in the background if +/// needed. Used by `distill mcp` before it returns a link. Returns the origin. +pub fn ensure_running(config: &LocalConfig, dirs: &Dirs) -> Result<String> { + let port = config.ui.port; + if probe(port).is_some() { + return Ok(origin(port)); + } + let exe = std::env::current_exe().context("finding the distill binary")?; + let log_path = dirs.data_dir.join("ui").join("server.log"); + if let Some(dir) = log_path.parent() { + std::fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?; + } + let log = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_path) + .with_context(|| format!("opening {}", log_path.display()))?; + let mut cmd = Command::new(&exe); + cmd.args(["ui", "--background"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(log); + detach(&mut cmd); + let mut child = cmd + .spawn() + .with_context(|| format!("starting {} ui --background", exe.display()))?; + // Reap the server when it exits so it does not linger as a zombie of this process. + std::thread::spawn(move || child.wait()); + if !wait_for_health(port, START_TIMEOUT) { + bail!( + "the web UI did not start on port {port} within {}s; see {}", + START_TIMEOUT.as_secs(), + log_path.display() + ); + } + Ok(origin(port)) +} + +/// Starts the server in its own process group so it outlives the agent session or the +/// terminal that started it. +fn detach(cmd: &mut Command) { + #[cfg(unix)] + std::os::unix::process::CommandExt::process_group(cmd, 0); + #[cfg(windows)] + { + const DETACHED_PROCESS: u32 = 0x0000_0008; + const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; + std::os::windows::process::CommandExt::creation_flags( + cmd, + DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP, + ); + } +} + +fn authorize_browser(dirs: &Dirs, origin: &str, open_browser: bool) -> Result<()> { + let auth = Auth::load_or_create(&dirs.data_dir.join("ui"))?; + let url = format!("{origin}/auth?token={}", auth.issue_token()?); + if open_browser { + if let Err(e) = open::that_detached(&url) { + eprintln!("distill ui: could not open a browser ({e}); open this link instead:"); + println!("{url}"); + } + } else { + println!("Open this link to authorize a browser (valid for 5 minutes, once):\n{url}"); + } + Ok(()) +} + +fn origin(port: u16) -> String { + format!("http://{}:{port}", distill_core::config::UI_HOST) +} + +fn agent() -> ureq::Agent { + ureq::Agent::config_builder() + .timeout_global(Some(Duration::from_secs(2))) + .http_status_as_error(false) + .build() + .into() +} + +/// The Distill server answering on `port`, if any. +fn probe(port: u16) -> Option<Health> { + let mut res = agent() + .get(format!("http://127.0.0.1:{port}/api/health")) + .header("Host", distill_core::config::UI_HOST) + .call() + .ok()?; + let body = res.body_mut().read_to_string().ok()?; + serde_json::from_str::<Health>(&body) + .ok() + .filter(|h| h.app == "distill") +} + +fn wait_for_health(port: u16, timeout: Duration) -> bool { + let deadline = Instant::now() + timeout; + loop { + if probe(port).is_some() { + return true; + } + if Instant::now() >= deadline { + return false; + } + std::thread::sleep(Duration::from_millis(100)); + } +} + +fn port_taken(port: u16) -> String { + let who = listeners::get_process_by_port(port, listeners::Protocol::TCP) + .map(|p| format!("{} (pid {})", p.name, p.pid)) + .unwrap_or_else(|_| "another program".into()); + format!( + "port {port} is in use by {who}. Stop it, or move Distill to another port with \ + `distill config set ui.port <port>`; links saved with the old port stop working." + ) +} diff --git a/crates/distill-cli/src/ui/server.rs b/crates/distill-cli/src/ui/server.rs new file mode 100644 index 0000000..39496b1 --- /dev/null +++ b/crates/distill-cli/src/ui/server.rs @@ -0,0 +1,339 @@ +//! The HTTP server behind `distill ui`: the embedded web app, the JSON API, and a +//! server-sent event stream that tells open pages when the vault changed. +//! +//! It listens on loopback only (`127.0.0.1` and `::1`), accepts only loopback host names +//! (against DNS rebinding), serves no CORS headers, and requires the session secret on every +//! API call (spec D7). + +use std::convert::Infallible; +use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr}; +use std::path::Path; +use std::time::Duration; + +use anyhow::{Context, Result}; +use axum::body::Body; +use axum::extract::{Query, Request, State}; +use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, Uri, header}; +use axum::middleware::{self, Next}; +use axum::response::sse::{Event, KeepAlive, Sse}; +use axum::response::{IntoResponse, Redirect, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use distill_core::config::{Dirs, UI_HOST}; +use notify_debouncer_mini::{DebounceEventResult, new_debouncer}; +use serde::Deserialize; +use tokio::net::TcpListener; +use tokio::sync::{broadcast, watch}; +use tokio_stream::wrappers::{BroadcastStream, WatchStream}; +use tokio_stream::{Stream, StreamExt}; + +use super::api::{self, ApiError}; +use super::auth::{self, Auth}; + +#[derive(Clone)] +pub struct AppState { + pub dirs: Dirs, + pub auth: Auth, + pub port: u16, + changes: broadcast::Sender<()>, + shutdown: watch::Sender<bool>, +} + +impl AppState { + pub fn notify_changed(&self) { + // No receivers just means no page is open. + let _ = self.changes.send(()); + } +} + +#[derive(rust_embed::Embed)] +#[folder = "$CARGO_MANIFEST_DIR/../../apps/web/dist/"] +#[allow_missing = true] +struct Assets; + +pub struct Bound { + listeners: Vec<TcpListener>, +} + +/// Binds the port on both loopback addresses. `::1` is skipped when the machine has no +/// IPv6 loopback; `distill.localhost` then resolves to `127.0.0.1` in browsers. +pub async fn bind(port: u16) -> std::io::Result<Bound> { + let v4 = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, port))).await?; + let mut listeners = vec![v4]; + match TcpListener::bind(SocketAddr::from((Ipv6Addr::LOCALHOST, port))).await { + Ok(v6) => listeners.push(v6), + Err(e) if e.kind() == std::io::ErrorKind::AddrInUse => return Err(e), + Err(e) => { + eprintln!("distill ui: not listening on [::1]:{port} ({e}); using 127.0.0.1 only") + } + } + Ok(Bound { listeners }) +} + +/// Serves until Ctrl-C or `POST /api/shutdown`. +pub async fn serve(bound: Bound, dirs: Dirs, vault: &Path, port: u16) -> Result<()> { + let auth = Auth::load_or_create(&dirs.data_dir.join("ui"))?; + let (changes, _) = broadcast::channel(16); + let (shutdown, _) = watch::channel(false); + let state = AppState { + dirs, + auth, + port, + changes, + shutdown, + }; + let _watcher = watch_vault(vault, state.clone())?; + let app = router(state.clone()); + + let mut servers = tokio::task::JoinSet::new(); + for listener in bound.listeners { + let app = app.clone(); + let mut stop = state.shutdown.subscribe(); + servers.spawn(async move { + axum::serve(listener, app) + .with_graceful_shutdown(async move { + let _ = stop.wait_for(|s| *s).await; + }) + .await + }); + } + let trigger = state.shutdown.clone(); + tokio::spawn(async move { + if tokio::signal::ctrl_c().await.is_ok() { + trigger.send_replace(true); + } + }); + while let Some(done) = servers.join_next().await { + done.context("server task panicked")? + .context("server stopped with an error")?; + } + Ok(()) +} + +fn router(state: AppState) -> Router { + let api = api::routes() + .route("/events", get(events)) + .route("/shutdown", post(shutdown)) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_session, + )); + Router::new() + .route("/api/health", get(|| async { Json(api::health()) })) + .route("/auth", get(authorize)) + .nest("/api", api) + .fallback(get(asset)) + .layer(middleware::from_fn_with_state(state.clone(), check_origin)) + .with_state(state) +} + +/// Rejects requests whose `Host` is not a loopback name (DNS rebinding) and requests sent +/// by a page on another origin. A cross-origin page could otherwise post to the API with +/// the cookie, because every `*.localhost` port counts as the same site. +async fn check_origin(State(s): State<AppState>, req: Request, next: Next) -> Response { + let headers = req.headers(); + let host_ok = headers + .get(header::HOST) + .and_then(|h| h.to_str().ok()) + .is_some_and(|h| allowed_host(h, None)); + if !host_ok { + return forbidden("unknown Host; open Distill at http://distill.localhost"); + } + let origin = headers.get(header::ORIGIN).and_then(|o| o.to_str().ok()); + let origin_ok = match origin { + Some(o) => o + .strip_prefix("http://") + .is_some_and(|h| allowed_host(h, Some(s.port))), + // Browsers send Origin with every request that can change something. + None => matches!(*req.method(), Method::GET | Method::HEAD) || bearer(headers).is_some(), + }; + if !origin_ok { + return forbidden("cross-origin request refused"); + } + next.run(req).await +} + +fn allowed_host(host: &str, port: Option<u16>) -> bool { + let (name, host_port) = match host.rsplit_once(':') { + Some((name, p)) if !name.is_empty() && !p.contains(']') => (name, p.parse::<u16>().ok()), + _ => (host, None), + }; + let name_ok = matches!(name, UI_HOST | "localhost" | "127.0.0.1" | "[::1]"); + name_ok && port.is_none_or(|p| host_port == Some(p)) +} + +async fn require_session(State(s): State<AppState>, req: Request, next: Next) -> Response { + let headers = req.headers(); + let presented = bearer(headers).or_else(|| { + headers + .get(header::COOKIE) + .and_then(|c| c.to_str().ok()) + .and_then(|c| auth::cookie_value(c, auth::COOKIE)) + }); + if !presented.is_some_and(|p| s.auth.check(p)) { + return ApiError::new( + StatusCode::UNAUTHORIZED, + "this browser is not authorized. Run `distill ui` in a terminal to open an authorized page.", + ) + .into_response(); + } + next.run(req).await +} + +fn bearer(headers: &HeaderMap) -> Option<&str> { + headers + .get(header::AUTHORIZATION)? + .to_str() + .ok()? + .strip_prefix("Bearer ") +} + +fn forbidden(message: &str) -> Response { + ApiError::new(StatusCode::FORBIDDEN, message).into_response() +} + +#[derive(Deserialize)] +struct AuthQuery { + token: String, + next: Option<String>, +} + +/// Redeems a one-time token from `distill ui` and sets the session cookie. +async fn authorize(State(s): State<AppState>, Query(q): Query<AuthQuery>) -> Response { + let next = q + .next + .filter(|n| n.starts_with('/') && !n.starts_with("//")) + .unwrap_or_else(|| "/".into()); + match s.auth.redeem(&q.token) { + Ok(true) => { + let mut res = Redirect::to(&next).into_response(); + if let Ok(cookie) = HeaderValue::from_str(&s.auth.set_cookie()) { + res.headers_mut().insert(header::SET_COOKIE, cookie); + } + res + } + Ok(false) => ( + StatusCode::FORBIDDEN, + "This link has expired or was already used. Run `distill ui` again to get a new one.", + ) + .into_response(), + Err(e) => { + ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")).into_response() + } + } +} + +async fn shutdown(State(s): State<AppState>, headers: HeaderMap) -> Response { + if bearer(&headers).is_none() { + return forbidden("shutdown is only available to `distill ui stop`"); + } + s.shutdown.send_replace(true); + StatusCode::NO_CONTENT.into_response() +} + +/// One `changed` event whenever the vault changes; ends when the server shuts down so +/// graceful shutdown does not wait on open pages. +async fn events(State(s): State<AppState>) -> Sse<impl Stream<Item = Result<Event, Infallible>>> { + let changes = BroadcastStream::new(s.changes.subscribe()) + .map(|_| Some(Event::default().event("changed").data("{}"))); + let stop = WatchStream::new(s.shutdown.subscribe()) + .filter(|stopping| *stopping) + .map(|_| None); + let stream = changes.merge(stop).map_while(|e| e.map(Ok)); + Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))) +} + +/// The web app. Hashed files under `assets/` are cached forever; any other path without a +/// file extension is a client-side route and gets `index.html`. +async fn asset(uri: Uri) -> Response { + let path = uri.path().trim_start_matches('/'); + if let Some(file) = Assets::get(path).filter(|_| !path.is_empty()) { + let cache = if path.starts_with("assets/") { + "public, max-age=31536000, immutable" + } else { + "no-cache" + }; + return file_response(&file, cache); + } + if path + .rsplit('/') + .next() + .is_some_and(|last| last.contains('.')) + { + return (StatusCode::NOT_FOUND, "not found").into_response(); + } + match Assets::get("index.html") { + Some(index) => file_response(&index, "no-cache"), + None => ( + StatusCode::SERVICE_UNAVAILABLE, + "This distill was built without the web UI. Run `pnpm -C apps/web build`, then rebuild distill.", + ) + .into_response(), + } +} + +fn file_response(file: &rust_embed::EmbeddedFile, cache: &'static str) -> Response { + let mut res = Response::new(Body::from(file.data.clone().into_owned())); + let headers = res.headers_mut(); + if let Ok(mime) = HeaderValue::from_str(file.metadata.mimetype()) { + headers.insert(header::CONTENT_TYPE, mime); + } + headers.insert(header::CACHE_CONTROL, HeaderValue::from_static(cache)); + headers.insert( + header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + ); + res +} + +/// Watches the vault for changes from other devices, editors and processes. Paths starting +/// with `.` (sync-tool metadata, Distill's temp files) are ignored. +fn watch_vault( + vault: &Path, + state: AppState, +) -> Result<notify_debouncer_mini::Debouncer<notify_debouncer_mini::notify::RecommendedWatcher>> { + let root = vault.to_path_buf(); + let mut debouncer = new_debouncer( + Duration::from_millis(300), + move |res: DebounceEventResult| match res { + Ok(events) => { + let relevant = events.iter().any(|e| { + e.path.strip_prefix(&root).is_ok_and(|rel| { + !rel.components() + .any(|c| c.as_os_str().to_string_lossy().starts_with('.')) + }) + }); + if relevant { + state.notify_changed(); + } + } + Err(e) => eprintln!("distill ui: file watcher error: {e}"), + }, + ) + .context("starting the vault file watcher")?; + debouncer + .watcher() + .watch( + vault, + notify_debouncer_mini::notify::RecursiveMode::Recursive, + ) + .with_context(|| format!("watching {}", vault.display()))?; + Ok(debouncer) +} + +#[cfg(test)] +mod tests { + use super::allowed_host; + + #[test] + fn host_names() { + assert!(allowed_host("distill.localhost:4777", None)); + assert!(allowed_host("127.0.0.1:4777", Some(4777))); + assert!(allowed_host("[::1]:4777", Some(4777))); + assert!(allowed_host("localhost", None)); + assert!(!allowed_host("evil.com:4777", None)); + assert!(!allowed_host("distill.localhost.evil.com", None)); + assert!(!allowed_host("other.localhost:4777", None)); + assert!(!allowed_host("distill.localhost:3000", Some(4777))); + } +} diff --git a/crates/distill-cli/tests/mcp.rs b/crates/distill-cli/tests/mcp.rs index 0b4a725..e6fc2c2 100644 --- a/crates/distill-cli/tests/mcp.rs +++ b/crates/distill-cli/tests/mcp.rs @@ -31,6 +31,18 @@ impl Env { .status() .unwrap(); assert!(status.success()); + // A port of its own: saving starts the web UI in the background. + let port = std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(); + let status = env + .command(&["config", "set", "ui.port", &port.to_string()]) + .stdout(Stdio::null()) + .status() + .unwrap(); + assert!(status.success()); env } @@ -47,6 +59,16 @@ impl Env { } } +impl Drop for Env { + fn drop(&mut self) { + let _ = self + .command(&["ui", "stop"]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } +} + struct Mcp { child: Child, stdin: ChildStdin, @@ -172,6 +194,24 @@ fn save_then_recall_reports_repeats_and_reopen_links() { let (err, first) = mcp.call("distill_save", save_args("new", SESSION)); assert!(!err, "{first}"); assert_eq!(first["ask_count"], 1); + assert_eq!( + first["warnings"], + json!([]), + "the web UI should have started" + ); + let url = first["url"].as_str().unwrap(); + let port = url.rsplit_once(':').unwrap().1.split('/').next().unwrap(); + let mut health = String::new(); + std::io::Read::read_to_string( + &mut ureq::get(format!("http://127.0.0.1:{port}/api/health")) + .call() + .unwrap() + .into_body() + .into_reader(), + &mut health, + ) + .unwrap(); + assert!(health.contains("\"app\":\"distill\""), "{health}"); let (_, recall) = mcp.call("distill_recall", json!({ "question": "bun 加载扩展失败" })); let topic = &recall["topics"][0]; @@ -180,6 +220,7 @@ fn save_then_recall_reports_repeats_and_reopen_links() { let note = &topic["notes"][0]; assert_eq!(note["reopen"]["link"], format!("codex://threads/{SESSION}")); assert!(std::path::Path::new(note["file"].as_str().unwrap()).is_file()); + assert_eq!(note["url"], url); let mut again = save_args(first["topic_id"].as_str().unwrap(), SESSION); again["new_tags"] = json!([]); diff --git a/crates/distill-cli/tests/ui.rs b/crates/distill-cli/tests/ui.rs new file mode 100644 index 0000000..77d643c --- /dev/null +++ b/crates/distill-cli/tests/ui.rs @@ -0,0 +1,254 @@ +#![allow(clippy::unwrap_used)] // Test helpers panic on setup failure by design. + +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::TcpStream; +use std::process::{Child, Command, Stdio}; +use std::time::Duration; + +use serde_json::{Value, json}; + +struct Server { + dir: tempfile::TempDir, + port: u16, + child: Child, + /// The one-time authorization link the server printed at start. + link: String, +} + +impl Server { + fn start() -> Self { + let dir = tempfile::tempdir().unwrap(); + let port = free_port(); + let vault = dir.path().join("vault"); + run(&dir, &["init", "--vault", vault.to_str().unwrap()]); + run(&dir, &["config", "set", "ui.port", &port.to_string()]); + let mut child = command(&dir, &["ui", "--no-open"]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .unwrap(); + let mut out = BufReader::new(child.stdout.take().unwrap()); + let mut link = String::new(); + while !link.starts_with("http://") { + link.clear(); + assert!(out.read_line(&mut link).unwrap() > 0, "server exited early"); + } + Self { + dir, + port, + child, + link: link.trim().to_string(), + } + } + + fn url(&self, path: &str) -> String { + format!("http://127.0.0.1:{}{path}", self.port) + } + + fn host(&self) -> String { + format!("distill.localhost:{}", self.port) + } + + fn origin(&self) -> String { + format!("http://{}", self.host()) + } + + /// Redeems the printed link and returns the session cookie it sets. + fn login(&self) -> String { + let path = self.link.split_once(&self.host()).unwrap().1; + let res = agent() + .get(self.url(path)) + .header("Host", self.host()) + .call() + .unwrap(); + assert_eq!(res.status(), 303); + let cookie = res.headers()["set-cookie"].to_str().unwrap(); + assert!(cookie.contains("HttpOnly") && cookie.contains("SameSite=Strict")); + cookie.split(';').next().unwrap().to_string() + } + + fn get(&self, path: &str, cookie: &str) -> (u16, Value) { + let mut res = agent() + .get(self.url(path)) + .header("Host", self.host()) + .header("Cookie", cookie) + .call() + .unwrap(); + let status = res.status().as_u16(); + ( + status, + serde_json::from_str(&res.body_mut().read_to_string().unwrap()).unwrap(), + ) + } + + fn post(&self, path: &str, cookie: &str, origin: &str, body: &Value) -> u16 { + agent() + .post(self.url(path)) + .header("Host", self.host()) + .header("Cookie", cookie) + .header("Origin", origin) + .header("Content-Type", "application/json") + .send(body.to_string()) + .unwrap() + .status() + .as_u16() + } +} + +impl Drop for Server { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +fn command(dir: &tempfile::TempDir, args: &[&str]) -> Command { + let mut cmd = Command::new(assert_cmd::cargo::cargo_bin("distill")); + cmd.env("DISTILL_HOME", dir.path().join("home")) + .env("CODEX_HOME", dir.path().join("codex")) + .env("CLAUDE_CONFIG_DIR", dir.path().join("claude")) + .env_remove("DISTILL_VAULT") + .env_remove("PORT") + .args(args); + cmd +} + +fn run(dir: &tempfile::TempDir, args: &[&str]) -> String { + let out = command(dir, args).output().unwrap(); + assert!( + out.status.success(), + "distill {args:?}: {}", + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8(out.stdout).unwrap() +} + +fn save_note(dir: &tempfile::TempDir) -> Value { + let request = json!({ + "title": "bun:sqlite 为什么加载不了扩展", + "question": "为什么 bun:sqlite 加载不了 sqlite-vec?", + "conclusion": "macOS 系统 SQLite 禁止加载扩展。", + "topic": "new", + "new_tags": ["sqlite"], + "source": { + "agent": "claude-code", + "session_id": "133e9647-5436-4a71-9352-39b822dfb773", + "cwd": "/Users/me/project" + } + }); + let mut child = command(dir, &["save", "--json"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(request.to_string().as_bytes()) + .unwrap(); + let out = child.wait_with_output().unwrap(); + assert!(out.status.success()); + serde_json::from_slice(&out.stdout).unwrap() +} + +fn agent() -> ureq::Agent { + ureq::Agent::config_builder() + .http_status_as_error(false) + .max_redirects(0) + .timeout_global(Some(Duration::from_secs(5))) + .build() + .into() +} + +fn free_port() -> u16 { + std::net::TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port() +} + +/// Sends a raw request so the test controls the Host header exactly. +fn raw_status(port: u16, host: &str, path: &str) -> String { + let mut stream = TcpStream::connect(("127.0.0.1", port)).unwrap(); + write!( + stream, + "GET {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n\r\n" + ) + .unwrap(); + let mut response = String::new(); + stream.read_to_string(&mut response).unwrap(); + response.lines().next().unwrap().to_string() +} + +#[test] +fn api_needs_a_loopback_host_and_the_session() { + let server = Server::start(); + assert!(raw_status(server.port, &server.host(), "/api/health").contains("200")); + assert!(raw_status(server.port, "evil.example:80", "/api/health").contains("403")); + assert!(raw_status(server.port, &server.host(), "/api/stats").contains("401")); + + let (status, _) = server.get("/api/stats", "distill_session=guess"); + assert_eq!(status, 401); +} + +#[test] +fn an_authorized_browser_reads_and_annotates() { + let server = Server::start(); + let saved = save_note(&server.dir); + let note_id = saved["note_id"].as_str().unwrap(); + let cookie = server.login(); + + let (status, notes) = server.get("/api/notes?tag=sqlite", &cookie); + assert_eq!(status, 200); + assert_eq!(notes[0]["id"], note_id); + + let (status, detail) = server.get(&format!("/api/notes/{note_id}"), &cookie); + assert_eq!(status, 200); + assert_eq!(detail["ask_count"], 1); + assert!( + detail["reopen"]["command"] + .as_str() + .unwrap() + .contains("claude --resume") + ); + + let path = format!("/api/notes/{note_id}/annotations"); + let body = json!({ "text": "我的理解" }); + assert_eq!( + server.post(&path, &cookie, "http://localhost:3000", &body), + 403, + "another local site must not write with the cookie" + ); + assert_eq!(server.post(&path, &cookie, &server.origin(), &body), 200); + let (_, detail) = server.get(&format!("/api/notes/{note_id}"), &cookie); + assert_eq!(detail["note"]["annotations"][0]["body"], "我的理解"); + + let (status, missing) = server.get("/api/notes/01NOPE", &cookie); + assert_eq!(status, 404); + assert!(missing["error"].as_str().unwrap().contains("01NOPE")); + + // The printed link works once. + let path = server.link.split_once(&server.host()).unwrap().1; + let again = agent() + .get(server.url(path)) + .header("Host", server.host()) + .call() + .unwrap(); + assert_eq!(again.status(), 403); +} + +#[test] +fn a_second_ui_reuses_the_server_and_stop_ends_it() { + let mut server = Server::start(); + let out = run(&server.dir, &["ui", "--no-open"]); + assert!(out.contains("/auth?token="), "{out}"); + + let out = run(&server.dir, &["ui", "stop"]); + assert!(out.contains("Stopped"), "{out}"); + let status = server.child.wait().unwrap(); + assert!(status.success()); + let out = run(&server.dir, &["ui", "stop"]); + assert!(out.contains("not running"), "{out}"); +} diff --git a/docs/index.md b/docs/index.md index 8db9703..558cc4a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,6 +10,7 @@ How Distill works today. Why it works this way is in | [development.md](development.md) | Toolchain, repo layout, `mise run check`, testing with a throwaway setup | | [vault-format.md](vault-format.md) | Vault layout and every file format in it; how the index is derived | | [plugin.md](plugin.md) | The Codex / Claude Code plugin: hook, MCP tools, skill, launcher | +| [web-ui.md](web-ui.md) | `distill ui`: address, background start, security, the JSON API | | [AGENTS.md](AGENTS.md) | Writing rules for every Markdown file | ## Packages diff --git a/docs/plugin.md b/docs/plugin.md index a4a9bb5..240849c 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -84,9 +84,9 @@ run `distill init`. | Tool | Returns | |---|---| -| `distill_recall` | Matching topics with `ask_count`, their notes (with `file`, annotations and `reopen`), and every tag in use | -| `distill_save` | The save result (`note_id`, `topic_id`, `ask_count`, `tags`, `file`) plus `warnings` | -| `distill_search` | Notes matching keywords and/or a tag, with `file` | +| `distill_recall` | Matching topics with `ask_count`, their notes (with `url`, `file`, annotations and `reopen`), every tag in use, and `warnings` | +| `distill_save` | The save result (`note_id`, `topic_id`, `ask_count`, `tags`, `url`, `file`) plus `warnings` | +| `distill_search` | Notes matching keywords and/or a tag, with `url` and `file` | | `distill_stats` | The same counts as `distill stats --json` | A failed validation comes back as a tool error whose text says what to change. Before saving, @@ -95,6 +95,11 @@ the server checks the source: a Codex session id must match a rollout file under that differs from the server's `CLAUDE_CODE_SESSION_ID` is kept and logged as a warning, because the id changes after `/clear` while the server keeps running. +`url` is the note's page in the web UI. Before returning one from `distill_save` or a +non-empty `distill_recall`, the server makes sure the web UI is running and starts it in the +background if not ([web-ui.md](web-ui.md)). When that fails, the tool still succeeds and the +reason is in `warnings`. `ui.autostart = false` turns this off. + `reopen` holds `link` (`codex://threads/<id>` or `claude://resume?session=<id>`) and `command` (`codex resume <id>`, or `cd <cwd> && claude --resume <id>`). diff --git a/docs/web-ui.md b/docs/web-ui.md new file mode 100644 index 0000000..677657e --- /dev/null +++ b/docs/web-ui.md @@ -0,0 +1,92 @@ +# Web UI + +`distill ui` serves a local web app for browsing notes, topics, tags and stats, and for the +few edits Distill makes outside an agent: annotations, topic names and merges, and sync +conflicts. The server lives in `crates/distill-cli/src/ui/`. + +## Address and lifetime + +The UI is always at `http://distill.localhost:<ui.port>`. `distill init` picks the port once +(4777 when free) and never changes it, so links in notes and terminals keep working. +`distill config set ui.port <port>` moves it; links handed out earlier then break. + +| Command | Does | +|---|---| +| `distill ui` | Starts the server in the foreground and opens an authorized browser tab. When a server is already running, only opens the tab | +| `distill ui --no-open` | Prints the authorization link instead of opening a browser | +| `distill ui --port <port>` | Serves on another port for this run. `PORT` does the same | +| `distill ui stop` | Stops the running server | + +`distill mcp` starts the server in the background (`distill ui --background`, in its own +process group) before it returns note links from `distill_save` or `distill_recall`. It +writes the server's stderr to `<data dir>/ui/server.log`. Turn this off with +`distill config set ui.autostart false`. The server runs until `distill ui stop` or logout; +nothing registers it as a system service. + +Only one server runs per port. Before binding, `distill ui` requests `/api/health`; a JSON +reply with `"app": "distill"` means a server is already there. When another program holds the +port, `distill ui` names it (process name and pid) and exits. + +The server watches the vault it opened at start. After `distill vault use` or `vault move`, +run `distill ui stop` and start it again. + +## Security + +Any web page the user has open can send requests to a loopback port. The server: + +- listens on `127.0.0.1` and `::1` only; +- answers only when `Host` is `distill.localhost`, `localhost`, `127.0.0.1` or `[::1]` + (DNS rebinding), and otherwise returns 403; +- refuses any request whose `Origin` is not one of those hosts on its own port, and any + non-GET request without an `Origin`, unless it carries the bearer secret. Every + `*.localhost` port counts as the same site, so `SameSite` alone does not stop another local + dev server from posting with the cookie; +- sends no CORS headers; +- requires the session secret on every `/api` route except `/api/health`. + +The secret lives in `<data dir>/ui/secret` (mode 0600). A browser gets it as the +`distill_session` cookie (`HttpOnly`, `SameSite=Strict`, 400 days) by opening +`/auth?token=<token>`: `distill ui` writes a one-time token under `<data dir>/ui/tokens/` and +opens that URL. A token works once and expires after 5 minutes. The cookie is bound to the host +name it was set on, so authorize `distill.localhost`, not `localhost`. Tokens never go into +notes or links, since the vault syncs. The CLI sends the secret as `Authorization: Bearer`. + +A browser without the cookie gets 401 from the API; the page then tells the user to run +`distill ui`. + +## API + +JSON over HTTP. Request and response types are Rust types in +`crates/distill-cli/src/ui/api.rs` and `crates/distill-core`; their TypeScript versions in +`apps/web/src/api/generated/` are generated by ts-rs when `cargo test` runs. Errors come back +as `{ "error": "<message>" }` with 400 (invalid input), 401, 403, 404 (unknown id) or 500. + +| Route | Returns / does | +|---|---| +| `GET /api/health` | `Health`; no session needed | +| `GET /api/session` | `Session`: version, vault path, UI origin | +| `GET /api/notes?q=&tag=&limit=` | `NoteHit[]`: keyword search, or recent notes when `q` is empty | +| `GET /api/notes/{id}` | `NoteDetail` | +| `POST /api/notes/{id}/annotations` | Adds an annotation (`AnnotationInput`); returns the updated `NoteDetail` | +| `PUT` / `DELETE /api/annotations/{id}` | Rewrites (`AnnotationInput`) or deletes an annotation | +| `GET /api/topics` | `TopicCount[]`, most asked first | +| `GET /api/topics/{id}` | `TopicDetail`; a merged-away id returns the topic it resolves to | +| `PUT /api/topics/{id}` | Renames (`TopicRename`) | +| `POST /api/topics/{id}/merge` | Merges this topic into `TopicMerge.into`; returns `Merged` | +| `GET /api/tags` | `TagCount[]` | +| `GET /api/stats` | `Stats` | +| `GET /api/recall?q=` | `RecallResult` | +| `GET /api/problems` | `Problems`: sync conflicts and unreadable files | +| `POST /api/conflicts/resolve` | Keeps one copy (`ConflictChoice`) and moves the rest to `<data dir>/discarded/` | +| `GET /api/events` | Server-sent events: `changed` after every edit and every vault change on disk | +| `POST /api/shutdown` | Bearer secret only; used by `distill ui stop` | + +Any other path serves the web app: files from `apps/web/dist` (embedded at build time), and +`index.html` for paths without a file extension. A build made without `apps/web/dist` +answers 503 with instructions. + +## Not verified yet + +`*.localhost` resolution on Linux and Windows. Chrome, Edge and Firefox resolve it +themselves; other browsers depend on the system resolver. On macOS the system resolver maps +`distill.localhost` to `::1`, which is why the server also listens there. diff --git a/plugins/distill/CHANGELOG.md b/plugins/distill/CHANGELOG.md index a57e0fb..993fdbd 100644 --- a/plugins/distill/CHANGELOG.md +++ b/plugins/distill/CHANGELOG.md @@ -7,6 +7,8 @@ skill, and a launcher that finds the `distill` binary outside the app's PATH. - Per-agent builds in each agent's native layout: `scripts/build-plugins.sh` (`mise run build:plugins`) writes `dist/plugins/claude-code` and `dist/plugins/codex`. +- The skill hands the user each note's web UI `url` instead of its file path, and points + annotations at the note's page. - Marketplace branches `marketplace-codex` and `marketplace-claude`, published by CI on every push to `main`. Published versions carry a build suffix such as `0.0.1+codex.<commit>`. diff --git a/plugins/distill/skills/distill/SKILL.md b/plugins/distill/skills/distill/SKILL.md index fd62641..8fe1114 100644 --- a/plugins/distill/skills/distill/SKILL.md +++ b/plugins/distill/skills/distill/SKILL.md @@ -36,7 +36,8 @@ question: - Build on the earlier conclusion and on the user's annotations (their own understanding). Address what the earlier note listed as still unclear. - Tell the user in one line, for example: "You asked this before (3 times); last note: <title> - (<file>)." `reopen.link` reopens the old session; `reopen.command` does it from a terminal. + (<url>)." `url` opens the note in the Distill web UI; `reopen.link` reopens the old + session; `reopen.command` does it from a terminal. When nothing is relevant, say nothing about Distill. @@ -63,7 +64,7 @@ When the user agrees, or asks to distill: Follow the style of the existing tags. 4. Write the note in the user's language. See [references/note-format.md](references/note-format.md). 5. Call `distill_save` with `source` copied from the `distill-source` line. -6. Tell the user in one or two lines: saved, the title, the file path, and when `ask_count` is +6. Tell the user in one or two lines: saved, the title, the `url`, and when `ask_count` is above 1, that this is the Nth time they asked. When `distill_save` returns an error: @@ -81,7 +82,11 @@ When `distill_save` returns an error: For questions like "what do I keep asking about?" or "which topics haven't I understood?": call `distill_stats` for counts, then `distill_search` or `distill_recall` to read notes. Topics asked two or more times without an annotation are the ones the user most likely has not -digested yet; say so. Users add annotations with `distill annotate <note-id> <text>`. +digested yet; say so. Users add annotations on a note's page (its `url`), or with +`distill annotate <note-id> <text>`. + +If a result has `warnings`, mention them in one line; the links in it may not open until the +user runs `distill ui`. ## Terms From 3bca927a985aa1e25eaff23b458635b6b45594df Mon Sep 17 00:00:00 2001 From: Yanlin <jackey.dou@gmail.com> Date: Sat, 26 Sep 2026 12:39:04 -0700 Subject: [PATCH 3/7] Add the web UI React single-page app in apps/web, embedded into distill and served by distill ui: home, notes search, note page with annotations and session reopen links, topic timelines with rename and merge, tags, stats and sync conflict resolution. API types are generated from Rust by ts-rs; mise run check now covers lint, types, tests and a build of the web app. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .github/workflows/ci.yml | 3 + .gitignore | 1 + INSTALL.md | 13 +- apps/web/CHANGELOG.md | 12 + apps/web/README.md | 55 + apps/web/biome.json | 18 + apps/web/index.html | 14 + apps/web/package.json | 44 + apps/web/public/favicon.svg | 1 + apps/web/src/api/bridge.test.ts | 49 + apps/web/src/api/bridge.ts | 60 + apps/web/src/api/client.ts | 50 + apps/web/src/api/generated/Agent.ts | 3 + apps/web/src/api/generated/AnnotationInput.ts | 3 + apps/web/src/api/generated/AnnotationView.ts | 3 + apps/web/src/api/generated/ApiErrorBody.ts | 3 + apps/web/src/api/generated/Conflict.ts | 3 + apps/web/src/api/generated/ConflictChoice.ts | 11 + apps/web/src/api/generated/Health.ts | 7 + apps/web/src/api/generated/InvalidFile.ts | 3 + apps/web/src/api/generated/Merged.ts | 7 + apps/web/src/api/generated/NoteDetail.ts | 17 + apps/web/src/api/generated/NoteHit.ts | 3 + apps/web/src/api/generated/NoteView.ts | 5 + apps/web/src/api/generated/NotesQuery.ts | 7 + apps/web/src/api/generated/Problems.ts | 5 + apps/web/src/api/generated/ProjectCount.ts | 3 + apps/web/src/api/generated/RecallQuery.ts | 3 + apps/web/src/api/generated/RecallResult.ts | 9 + apps/web/src/api/generated/RecallTopic.ts | 8 + apps/web/src/api/generated/Reopen.ts | 8 + apps/web/src/api/generated/Session.ts | 7 + apps/web/src/api/generated/Source.ts | 20 + apps/web/src/api/generated/Stats.ts | 7 + apps/web/src/api/generated/TagCount.ts | 3 + apps/web/src/api/generated/TopicCount.ts | 3 + apps/web/src/api/generated/TopicDetail.ts | 22 + apps/web/src/api/generated/TopicMerge.ts | 7 + apps/web/src/api/generated/TopicRef.ts | 3 + apps/web/src/api/generated/TopicRename.ts | 3 + apps/web/src/api/generated/WeekCount.ts | 3 + apps/web/src/api/queries.ts | 54 + apps/web/src/components/Annotations.tsx | 156 + apps/web/src/components/CommandPalette.tsx | 129 + apps/web/src/components/Layout.tsx | 132 + apps/web/src/components/Markdown.tsx | 11 + apps/web/src/components/NoteList.tsx | 50 + apps/web/src/components/ThemeToggle.tsx | 69 + apps/web/src/components/TopicPicker.tsx | 53 + apps/web/src/components/TopicRows.tsx | 36 + apps/web/src/components/charts.tsx | 180 + apps/web/src/components/ui.tsx | 165 + apps/web/src/lib/format.test.ts | 37 + apps/web/src/lib/format.ts | 75 + apps/web/src/lib/weeks.test.ts | 15 + apps/web/src/lib/weeks.ts | 33 + apps/web/src/main.tsx | 22 + apps/web/src/pages/HomePage.tsx | 85 + apps/web/src/pages/NotePage.tsx | 107 + apps/web/src/pages/NotesPage.tsx | 48 + apps/web/src/pages/ProblemsPage.tsx | 83 + apps/web/src/pages/StatsPage.tsx | 73 + apps/web/src/pages/TagsPage.tsx | 48 + apps/web/src/pages/TopicPage.tsx | 205 ++ apps/web/src/pages/TopicsPage.tsx | 65 + apps/web/src/router.tsx | 52 + apps/web/src/styles.css | 183 + apps/web/tsconfig.json | 18 + apps/web/vite.config.ts | 24 + docs/development.md | 23 +- docs/index.md | 1 + mise.toml | 26 +- package.json | 5 + pnpm-lock.yaml | 3271 +++++++++++++++++ pnpm-workspace.yaml | 2 + 75 files changed, 6036 insertions(+), 9 deletions(-) create mode 100644 apps/web/CHANGELOG.md create mode 100644 apps/web/README.md create mode 100644 apps/web/biome.json create mode 100644 apps/web/index.html create mode 100644 apps/web/package.json create mode 100644 apps/web/public/favicon.svg create mode 100644 apps/web/src/api/bridge.test.ts create mode 100644 apps/web/src/api/bridge.ts create mode 100644 apps/web/src/api/client.ts create mode 100644 apps/web/src/api/generated/Agent.ts create mode 100644 apps/web/src/api/generated/AnnotationInput.ts create mode 100644 apps/web/src/api/generated/AnnotationView.ts create mode 100644 apps/web/src/api/generated/ApiErrorBody.ts create mode 100644 apps/web/src/api/generated/Conflict.ts create mode 100644 apps/web/src/api/generated/ConflictChoice.ts create mode 100644 apps/web/src/api/generated/Health.ts create mode 100644 apps/web/src/api/generated/InvalidFile.ts create mode 100644 apps/web/src/api/generated/Merged.ts create mode 100644 apps/web/src/api/generated/NoteDetail.ts create mode 100644 apps/web/src/api/generated/NoteHit.ts create mode 100644 apps/web/src/api/generated/NoteView.ts create mode 100644 apps/web/src/api/generated/NotesQuery.ts create mode 100644 apps/web/src/api/generated/Problems.ts create mode 100644 apps/web/src/api/generated/ProjectCount.ts create mode 100644 apps/web/src/api/generated/RecallQuery.ts create mode 100644 apps/web/src/api/generated/RecallResult.ts create mode 100644 apps/web/src/api/generated/RecallTopic.ts create mode 100644 apps/web/src/api/generated/Reopen.ts create mode 100644 apps/web/src/api/generated/Session.ts create mode 100644 apps/web/src/api/generated/Source.ts create mode 100644 apps/web/src/api/generated/Stats.ts create mode 100644 apps/web/src/api/generated/TagCount.ts create mode 100644 apps/web/src/api/generated/TopicCount.ts create mode 100644 apps/web/src/api/generated/TopicDetail.ts create mode 100644 apps/web/src/api/generated/TopicMerge.ts create mode 100644 apps/web/src/api/generated/TopicRef.ts create mode 100644 apps/web/src/api/generated/TopicRename.ts create mode 100644 apps/web/src/api/generated/WeekCount.ts create mode 100644 apps/web/src/api/queries.ts create mode 100644 apps/web/src/components/Annotations.tsx create mode 100644 apps/web/src/components/CommandPalette.tsx create mode 100644 apps/web/src/components/Layout.tsx create mode 100644 apps/web/src/components/Markdown.tsx create mode 100644 apps/web/src/components/NoteList.tsx create mode 100644 apps/web/src/components/ThemeToggle.tsx create mode 100644 apps/web/src/components/TopicPicker.tsx create mode 100644 apps/web/src/components/TopicRows.tsx create mode 100644 apps/web/src/components/charts.tsx create mode 100644 apps/web/src/components/ui.tsx create mode 100644 apps/web/src/lib/format.test.ts create mode 100644 apps/web/src/lib/format.ts create mode 100644 apps/web/src/lib/weeks.test.ts create mode 100644 apps/web/src/lib/weeks.ts create mode 100644 apps/web/src/main.tsx create mode 100644 apps/web/src/pages/HomePage.tsx create mode 100644 apps/web/src/pages/NotePage.tsx create mode 100644 apps/web/src/pages/NotesPage.tsx create mode 100644 apps/web/src/pages/ProblemsPage.tsx create mode 100644 apps/web/src/pages/StatsPage.tsx create mode 100644 apps/web/src/pages/TagsPage.tsx create mode 100644 apps/web/src/pages/TopicPage.tsx create mode 100644 apps/web/src/pages/TopicsPage.tsx create mode 100644 apps/web/src/router.tsx create mode 100644 apps/web/src/styles.css create mode 100644 apps/web/tsconfig.json create mode 100644 apps/web/vite.config.ts create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e31a95..9d27ff1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,9 @@ jobs: - uses: jdx/mise-action@v4 - uses: Swatinem/rust-cache@v2 - run: mise run check + # The web UI's API types are generated from Rust by `cargo test`; they must be committed. + - name: Generated API types are up to date + run: git diff --exit-code -- apps/web/src/api/generated # Each marketplace branch holds one agent's build and nothing else; see docs/plugin.md. publish-plugin: diff --git a/.gitignore b/.gitignore index 9a1ed37..c298390 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ .DS_Store /dist +/apps/web/dist diff --git a/INSTALL.md b/INSTALL.md index 491051e..d9d8db8 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -9,14 +9,16 @@ steps that are already done, and ask the user only where a step says so. Check: `distill --version` prints `distill 0.0.1` or later. -If it is missing, build it (needs a Rust toolchain; `rustup` or `mise` provides one): +If it is missing, build it from a clone of `<source>`. It needs Rust, Node 22 and pnpm; +`mise install` provides all three. ```bash -cargo install --path crates/distill-cli +mise run install ``` -Run that from a clone of `<source>`. With only a Git URL: -`cargo install --git <source> distill-cli`. +That builds the web UI and then runs `cargo install --path crates/distill-cli --locked`, which +embeds it. `cargo install --git <source> distill-cli` also works but leaves out the web UI: +`distill ui` then shows a page asking for a rebuild. ## 2. This device @@ -69,6 +71,9 @@ distill doctor It should list the plugin under "Plugins" and end with "All good." Then tell the user to open a new session: plugins load when a session starts. +Finally, run `distill ui` once. It opens the web UI in the browser and authorizes it; links +the agent hands out later open without asking again. + ## Uninstall `codex plugin remove distill@distill`, `claude plugin uninstall distill@distill`, and diff --git a/apps/web/CHANGELOG.md b/apps/web/CHANGELOG.md new file mode 100644 index 0000000..3c4b2b5 --- /dev/null +++ b/apps/web/CHANGELOG.md @@ -0,0 +1,12 @@ +# Changelog + +## [Unreleased] + +### Added +- Web UI served by `distill ui`: home, notes search, note page, topics with timelines, tags, + stats and a problems page. See `README.md`. +- Annotations can be added, edited and deleted on the note page; topics can be renamed and + merged; sync conflicts can be settled by keeping one copy. +- ⌘K palette over notes, topics and tags; light and dark themes following the system setting. +- Pages refetch when the server reports a vault change, so notes saved from an agent appear + without a reload. diff --git a/apps/web/README.md b/apps/web/README.md new file mode 100644 index 0000000..ef0ef26 --- /dev/null +++ b/apps/web/README.md @@ -0,0 +1,55 @@ +# @distill/web + +The web UI that `distill ui` serves. A React single-page app built by Vite into +`apps/web/dist`, which `crates/distill-cli` embeds at compile time. It never touches the vault +or index; everything goes through the JSON API in [docs/web-ui.md](../../docs/web-ui.md). + +## Stack + +React 19 with the React Compiler, TanStack Router (code-based routes in `src/router.tsx`) and +TanStack Query, Tailwind v4, `virtua` for long lists, `cmdk` for the ⌘K palette, +`react-markdown` with GFM for note bodies. Fonts come from `@fontsource` packages (IBM Plex +Serif, iA Writer Quattro, Lilex; Latin subsets) and ship inside the bundle, so the UI works +offline. Chinese text falls back to the system's CJK fonts. + +## Layout + +| Path | Holds | +|---|---| +| `src/api/bridge.ts` | The only transport. `setBridge` swaps it in tests | +| `src/api/client.ts` | One typed function per API route | +| `src/api/queries.ts` | Query keys, the query client, the SSE subscription that refetches on change | +| `src/api/generated/` | TypeScript API types generated from Rust by ts-rs. Never edit by hand | +| `src/pages/` | One component per route | +| `src/components/` | Shared pieces: layout, note list, annotations, charts, command palette | +| `src/lib/` | Formatting and ISO-week helpers, with tests | +| `src/styles.css` | Design tokens (semantic color roles, fonts) and note prose styles | + +Design direction and token rules: the +[distill-ui-design skill](../../.agents/skills/distill-ui-design/SKILL.md). + +## Pages + +| Route | Shows | +|---|---| +| `/` | Most-asked topics, repeated topics without an annotation, recent notes, a banner when files need attention | +| `/notes?q=` | Keyword search, or recent notes | +| `/notes/$noteId` | The note, its topic and ask count, tags, annotations (add, edit, delete), how to reopen the session | +| `/topics?view=asked\|recent\|undigested` | Every topic | +| `/topics/$topicId` | Timeline of the topic's notes; rename; merge into another topic | +| `/tags`, `/tags/$tag` | Tags by note count; notes with a tag | +| `/stats` | Counts, notes per ISO week (16 weeks), most-asked and unannotated topics, tags, projects | +| `/problems` | Sync conflicts (keep one copy) and unreadable files | + +## Scripts + +Run from the repo root with `pnpm -C apps/web <script>`, or through `mise run check`. + +| Script | Does | +|---|---| +| `dev` | Vite dev server; proxies `/api` and `/auth` to `distill ui` on `DISTILL_UI_PORT` (default 4777) | +| `build` | Writes `dist/` | +| `typecheck` / `lint` / `test` | `tsc`, Biome, Vitest | + +With `pnpm dev`, authorize the dev origin by opening the link `distill ui --no-open` prints +with its host replaced by `localhost:5173`. diff --git a/apps/web/biome.json b/apps/web/biome.json new file mode 100644 index 0000000..be7b5ce --- /dev/null +++ b/apps/web/biome.json @@ -0,0 +1,18 @@ +{ + "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", + "files": { + "includes": ["**", "!dist", "!src/api/generated"] + }, + "formatter": { + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "css": { + "parser": { "tailwindDirectives": true } + }, + "linter": { + "rules": { "preset": "recommended" } + }, + "assist": { "actions": { "source": { "organizeImports": "on" } } } +} diff --git a/apps/web/index.html b/apps/web/index.html new file mode 100644 index 0000000..fee93cb --- /dev/null +++ b/apps/web/index.html @@ -0,0 +1,14 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <meta name="color-scheme" content="light dark" /> + <link rel="icon" href="/favicon.svg" type="image/svg+xml" /> + <title>Distill + + +
+ + + diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000..67dbf71 --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,44 @@ +{ + "name": "@distill/web", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "typecheck": "tsc --noEmit", + "lint": "biome check .", + "format": "biome check --write .", + "test": "vitest run" + }, + "dependencies": { + "@fontsource/ia-writer-quattro": "5.3.0", + "@fontsource/ibm-plex-serif": "5.3.0", + "@fontsource/lilex": "5.3.0", + "@tanstack/react-query": "5.104.0", + "@tanstack/react-router": "1.170.39", + "cmdk": "1.1.1", + "lucide-react": "1.48.0", + "react": "19.3.0", + "react-dom": "19.3.0", + "react-markdown": "10.1.0", + "remark-gfm": "4.0.1", + "virtua": "0.52.7" + }, + "devDependencies": { + "@babel/core": "^8.0.6", + "@biomejs/biome": "2.5.14", + "@rolldown/plugin-babel": "^0.2.4", + "@tailwindcss/vite": "4.3.3", + "@types/babel__core": "^7.20.5", + "@types/node": "^22.20.4", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "6.1.1", + "babel-plugin-react-compiler": "1.0.0", + "tailwindcss": "4.3.3", + "typescript": "7.0.2", + "vite": "8.3.1", + "vitest": "5.0.2" + } +} diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg new file mode 100644 index 0000000..91c72e8 --- /dev/null +++ b/apps/web/public/favicon.svg @@ -0,0 +1 @@ +Distill diff --git a/apps/web/src/api/bridge.test.ts b/apps/web/src/api/bridge.test.ts new file mode 100644 index 0000000..0cb7784 --- /dev/null +++ b/apps/web/src/api/bridge.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ApiError, httpBridge } from "./bridge"; +import { queryString } from "./client"; + +afterEach(() => vi.unstubAllGlobals()); + +function respond(status: number, body: string) { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(status === 204 ? null : body, { status })), + ); +} + +describe("httpBridge", () => { + it("returns parsed JSON", async () => { + respond(200, '{"notes":3}'); + await expect(httpBridge.request("GET", "/stats")).resolves.toEqual({ notes: 3 }); + }); + + it("turns the server's error body into an ApiError", async () => { + respond(404, '{"error":"unknown note 01X."}'); + const err = await httpBridge.request("GET", "/notes/01X").catch((e: unknown) => e); + expect(err).toBeInstanceOf(ApiError); + expect((err as ApiError).status).toBe(404); + expect((err as ApiError).message).toBe("unknown note 01X."); + }); + + it("keeps non-JSON error text", async () => { + respond(502, "Bad Gateway"); + await expect(httpBridge.request("GET", "/stats")).rejects.toThrow("Bad Gateway"); + }); + + it("sends JSON bodies and accepts 204", async () => { + respond(204, ""); + await expect(httpBridge.request("PUT", "/topics/1", { label: "x" })).resolves.toBeUndefined(); + const call = vi.mocked(fetch).mock.calls[0]; + expect(call?.[0]).toBe("/api/topics/1"); + expect(call?.[1]?.body).toBe('{"label":"x"}'); + }); +}); + +describe("queryString", () => { + it("skips empty values", () => { + expect(queryString({ q: "", tag: "sqlite", limit: 20, topic: undefined })).toBe( + "?tag=sqlite&limit=20", + ); + expect(queryString({})).toBe(""); + }); +}); diff --git a/apps/web/src/api/bridge.ts b/apps/web/src/api/bridge.ts new file mode 100644 index 0000000..aa16240 --- /dev/null +++ b/apps/web/src/api/bridge.ts @@ -0,0 +1,60 @@ +// The one place the UI talks to `distill ui`. Tests swap in a fake with `setBridge`; a +// future desktop shell would swap in its own transport (spec D7). + +export type Method = "GET" | "POST" | "PUT" | "DELETE"; + +export interface Bridge { + request(method: Method, path: string, body?: unknown): Promise; +} + +/** An API response that was not 2xx. `message` is the server's `error` text. */ +export class ApiError extends Error { + readonly status: number; + + constructor(status: number, message: string) { + super(message); + this.name = "ApiError"; + this.status = status; + } +} + +export const httpBridge: Bridge = { + async request(method: Method, path: string, body?: unknown): Promise { + const res = await fetch(`/api${path}`, { + method, + credentials: "same-origin", + headers: body === undefined ? undefined : { "Content-Type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (!res.ok) { + throw new ApiError(res.status, await errorText(res)); + } + if (res.status === 204) { + return undefined as T; + } + return (await res.json()) as T; + }, +}; + +async function errorText(res: Response): Promise { + const text = await res.text(); + try { + const parsed = JSON.parse(text) as { error?: unknown }; + if (typeof parsed.error === "string") { + return parsed.error; + } + } catch { + // Not JSON: a proxy or the static fallback answered. Show the raw text below. + } + return text || `HTTP ${res.status} for ${res.url}`; +} + +let current: Bridge = httpBridge; + +export function bridge(): Bridge { + return current; +} + +export function setBridge(next: Bridge): void { + current = next; +} diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts new file mode 100644 index 0000000..4dcf28a --- /dev/null +++ b/apps/web/src/api/client.ts @@ -0,0 +1,50 @@ +import { bridge } from "./bridge"; +import type { AnnotationInput } from "./generated/AnnotationInput"; +import type { ConflictChoice } from "./generated/ConflictChoice"; +import type { Merged } from "./generated/Merged"; +import type { NoteDetail } from "./generated/NoteDetail"; +import type { NoteHit } from "./generated/NoteHit"; +import type { NotesQuery } from "./generated/NotesQuery"; +import type { Problems } from "./generated/Problems"; +import type { Session } from "./generated/Session"; +import type { Stats } from "./generated/Stats"; +import type { TagCount } from "./generated/TagCount"; +import type { TopicCount } from "./generated/TopicCount"; +import type { TopicDetail } from "./generated/TopicDetail"; +import type { TopicMerge } from "./generated/TopicMerge"; +import type { TopicRename } from "./generated/TopicRename"; + +const enc = encodeURIComponent; + +export function queryString(params: object): string { + const search = new URLSearchParams(); + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null && value !== "") { + search.set(key, String(value)); + } + } + const text = search.toString(); + return text ? `?${text}` : ""; +} + +export const api = { + session: () => bridge().request("GET", "/session"), + notes: (q: NotesQuery) => bridge().request("GET", `/notes${queryString(q)}`), + note: (id: string) => bridge().request("GET", `/notes/${enc(id)}`), + addAnnotation: (noteId: string, body: AnnotationInput) => + bridge().request("POST", `/notes/${enc(noteId)}/annotations`, body), + updateAnnotation: (id: string, body: AnnotationInput) => + bridge().request("PUT", `/annotations/${enc(id)}`, body), + deleteAnnotation: (id: string) => bridge().request("DELETE", `/annotations/${enc(id)}`), + topics: () => bridge().request("GET", "/topics"), + topic: (id: string) => bridge().request("GET", `/topics/${enc(id)}`), + renameTopic: (id: string, body: TopicRename) => + bridge().request("PUT", `/topics/${enc(id)}`, body), + mergeTopic: (id: string, body: TopicMerge) => + bridge().request("POST", `/topics/${enc(id)}/merge`, body), + tags: () => bridge().request("GET", "/tags"), + stats: () => bridge().request("GET", "/stats"), + problems: () => bridge().request("GET", "/problems"), + resolveConflict: (body: ConflictChoice) => + bridge().request("POST", "/conflicts/resolve", body), +}; diff --git a/apps/web/src/api/generated/Agent.ts b/apps/web/src/api/generated/Agent.ts new file mode 100644 index 0000000..49ec3ef --- /dev/null +++ b/apps/web/src/api/generated/Agent.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type Agent = "codex" | "claude-code"; diff --git a/apps/web/src/api/generated/AnnotationInput.ts b/apps/web/src/api/generated/AnnotationInput.ts new file mode 100644 index 0000000..9c86912 --- /dev/null +++ b/apps/web/src/api/generated/AnnotationInput.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AnnotationInput = { text: string, }; diff --git a/apps/web/src/api/generated/AnnotationView.ts b/apps/web/src/api/generated/AnnotationView.ts new file mode 100644 index 0000000..04f07e6 --- /dev/null +++ b/apps/web/src/api/generated/AnnotationView.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AnnotationView = { id: string, body: string, created: string, updated: string | null, }; diff --git a/apps/web/src/api/generated/ApiErrorBody.ts b/apps/web/src/api/generated/ApiErrorBody.ts new file mode 100644 index 0000000..17498f3 --- /dev/null +++ b/apps/web/src/api/generated/ApiErrorBody.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ApiErrorBody = { error: string, }; diff --git a/apps/web/src/api/generated/Conflict.ts b/apps/web/src/api/generated/Conflict.ts new file mode 100644 index 0000000..f3e303a --- /dev/null +++ b/apps/web/src/api/generated/Conflict.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type Conflict = { kind: string, id: string, paths: Array, }; diff --git a/apps/web/src/api/generated/ConflictChoice.ts b/apps/web/src/api/generated/ConflictChoice.ts new file mode 100644 index 0000000..0709d06 --- /dev/null +++ b/apps/web/src/api/generated/ConflictChoice.ts @@ -0,0 +1,11 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ConflictChoice = { +/** + * `note`, `topic` or `annotation`. + */ +kind: string, id: string, +/** + * Vault-relative path of the copy to keep. + */ +keep: string, }; diff --git a/apps/web/src/api/generated/Health.ts b/apps/web/src/api/generated/Health.ts new file mode 100644 index 0000000..00fe59d --- /dev/null +++ b/apps/web/src/api/generated/Health.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type Health = { +/** + * Always `"distill"`: tells a Distill server apart from another program on the port. + */ +app: string, version: string, pid: number, }; diff --git a/apps/web/src/api/generated/InvalidFile.ts b/apps/web/src/api/generated/InvalidFile.ts new file mode 100644 index 0000000..959f75d --- /dev/null +++ b/apps/web/src/api/generated/InvalidFile.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type InvalidFile = { path: string, error: string, }; diff --git a/apps/web/src/api/generated/Merged.ts b/apps/web/src/api/generated/Merged.ts new file mode 100644 index 0000000..db7e26b --- /dev/null +++ b/apps/web/src/api/generated/Merged.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type Merged = { +/** + * The topic both now resolve to. + */ +topic_id: string, }; diff --git a/apps/web/src/api/generated/NoteDetail.ts b/apps/web/src/api/generated/NoteDetail.ts new file mode 100644 index 0000000..39cb70f --- /dev/null +++ b/apps/web/src/api/generated/NoteDetail.ts @@ -0,0 +1,17 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { NoteView } from "./NoteView"; +import type { Reopen } from "./Reopen"; + +export type NoteDetail = { note: NoteView, +/** + * The note body as written in the file, starting with `# `. + */ +markdown: string, +/** + * Absolute path of the note file, for opening it in an editor. + */ +file: string, topic_label: string, +/** + * Notes in the note's topic, this one included. + */ +ask_count: number, reopen: Reopen, }; diff --git a/apps/web/src/api/generated/NoteHit.ts b/apps/web/src/api/generated/NoteHit.ts new file mode 100644 index 0000000..912d93d --- /dev/null +++ b/apps/web/src/api/generated/NoteHit.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type NoteHit = { id: string, topic_id: string, title: string, conclusion: string, tags: Array<string>, created: string, path: string, }; diff --git a/apps/web/src/api/generated/NoteView.ts b/apps/web/src/api/generated/NoteView.ts new file mode 100644 index 0000000..4ab699b --- /dev/null +++ b/apps/web/src/api/generated/NoteView.ts @@ -0,0 +1,5 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AnnotationView } from "./AnnotationView"; +import type { Source } from "./Source"; + +export type NoteView = { id: string, topic_id: string, title: string, question: string, conclusion: string, tags: Array<string>, created: string, source: Source, path: string, annotations: Array<AnnotationView>, }; diff --git a/apps/web/src/api/generated/NotesQuery.ts b/apps/web/src/api/generated/NotesQuery.ts new file mode 100644 index 0000000..5455271 --- /dev/null +++ b/apps/web/src/api/generated/NotesQuery.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type NotesQuery = { +/** + * Keywords; empty lists the most recent notes. + */ +q?: string, tag?: string, limit?: number, }; diff --git a/apps/web/src/api/generated/Problems.ts b/apps/web/src/api/generated/Problems.ts new file mode 100644 index 0000000..42cb94a --- /dev/null +++ b/apps/web/src/api/generated/Problems.ts @@ -0,0 +1,5 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Conflict } from "./Conflict"; +import type { InvalidFile } from "./InvalidFile"; + +export type Problems = { conflicts: Array<Conflict>, invalid_files: Array<InvalidFile>, }; diff --git a/apps/web/src/api/generated/ProjectCount.ts b/apps/web/src/api/generated/ProjectCount.ts new file mode 100644 index 0000000..a0dd15e --- /dev/null +++ b/apps/web/src/api/generated/ProjectCount.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ProjectCount = { project: string, notes: number, }; diff --git a/apps/web/src/api/generated/RecallQuery.ts b/apps/web/src/api/generated/RecallQuery.ts new file mode 100644 index 0000000..b657e43 --- /dev/null +++ b/apps/web/src/api/generated/RecallQuery.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type RecallQuery = { q: string, limit?: number, }; diff --git a/apps/web/src/api/generated/RecallResult.ts b/apps/web/src/api/generated/RecallResult.ts new file mode 100644 index 0000000..8d85ae0 --- /dev/null +++ b/apps/web/src/api/generated/RecallResult.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { RecallTopic } from "./RecallTopic"; +import type { TagCount } from "./TagCount"; + +export type RecallResult = { topics: Array<RecallTopic>, +/** + * Every tag in use, so the agent can reuse one instead of inventing a near-duplicate. + */ +tags: Array<TagCount>, }; diff --git a/apps/web/src/api/generated/RecallTopic.ts b/apps/web/src/api/generated/RecallTopic.ts new file mode 100644 index 0000000..bcfa0ef --- /dev/null +++ b/apps/web/src/api/generated/RecallTopic.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { NoteView } from "./NoteView"; + +export type RecallTopic = { topic_id: string, label: string, +/** + * Notes in this topic, i.e. how many times the question was distilled. + */ +ask_count: number, notes: Array<NoteView>, }; diff --git a/apps/web/src/api/generated/Reopen.ts b/apps/web/src/api/generated/Reopen.ts new file mode 100644 index 0000000..9dff6e9 --- /dev/null +++ b/apps/web/src/api/generated/Reopen.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * How to get back to the session a note came from: a desktop-app deep link and a + * terminal command that works without the app. Neither URL scheme is documented by its + * vendor; both were verified by hand on 2026-09-13 (spec D6). + */ +export type Reopen = { link: string, command: string, }; diff --git a/apps/web/src/api/generated/Session.ts b/apps/web/src/api/generated/Session.ts new file mode 100644 index 0000000..9834900 --- /dev/null +++ b/apps/web/src/api/generated/Session.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type Session = { version: string, vault: string, +/** + * `http://distill.localhost:<port>`, the address links point at. + */ +origin: string, }; diff --git a/apps/web/src/api/generated/Source.ts b/apps/web/src/api/generated/Source.ts new file mode 100644 index 0000000..da6119c --- /dev/null +++ b/apps/web/src/api/generated/Source.ts @@ -0,0 +1,20 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Agent } from "./Agent"; + +/** + * Where a note came from. Copy the values from the `distill-source:` line the Distill hook + * injects: `distill-source: <agent> <session_id> <cwd>`. + */ +export type Source = { agent: Agent, +/** + * UUID of the agent session. + */ +session_id: string, +/** + * Working directory of the session. + */ +cwd: string, +/** + * Git remote of the project, if known. + */ +git_repo?: string, }; diff --git a/apps/web/src/api/generated/Stats.ts b/apps/web/src/api/generated/Stats.ts new file mode 100644 index 0000000..0897012 --- /dev/null +++ b/apps/web/src/api/generated/Stats.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ProjectCount } from "./ProjectCount"; +import type { TagCount } from "./TagCount"; +import type { TopicCount } from "./TopicCount"; +import type { WeekCount } from "./WeekCount"; + +export type Stats = { notes: number, topics: number, repeated_topics: number, annotations: number, top_topics: Array<TopicCount>, tags: Array<TagCount>, by_week: Array<WeekCount>, by_project: Array<ProjectCount>, conflicts: number, invalid_files: number, }; diff --git a/apps/web/src/api/generated/TagCount.ts b/apps/web/src/api/generated/TagCount.ts new file mode 100644 index 0000000..61588c3 --- /dev/null +++ b/apps/web/src/api/generated/TagCount.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type TagCount = { tag: string, notes: number, }; diff --git a/apps/web/src/api/generated/TopicCount.ts b/apps/web/src/api/generated/TopicCount.ts new file mode 100644 index 0000000..635f70f --- /dev/null +++ b/apps/web/src/api/generated/TopicCount.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type TopicCount = { topic_id: string, label: string, ask_count: number, first_asked: string, last_asked: string, annotated: boolean, }; diff --git a/apps/web/src/api/generated/TopicDetail.ts b/apps/web/src/api/generated/TopicDetail.ts new file mode 100644 index 0000000..f140278 --- /dev/null +++ b/apps/web/src/api/generated/TopicDetail.ts @@ -0,0 +1,22 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { NoteView } from "./NoteView"; +import type { TopicRef } from "./TopicRef"; + +export type TopicDetail = { +/** + * The topic after following `merged_into`; differs from the requested id when that + * topic was merged away. + */ +topic_id: string, label: string, created: string, ask_count: number, +/** + * Distinct agent sessions the question was distilled in. + */ +sessions: number, +/** + * Topics merged into this one. + */ +merged_from: Array<TopicRef>, +/** + * Oldest first, so the page reads as a timeline. + */ +notes: Array<NoteView>, }; diff --git a/apps/web/src/api/generated/TopicMerge.ts b/apps/web/src/api/generated/TopicMerge.ts new file mode 100644 index 0000000..b072213 --- /dev/null +++ b/apps/web/src/api/generated/TopicMerge.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type TopicMerge = { +/** + * The topic to merge into. The topic in the URL is the one that goes away. + */ +into: string, }; diff --git a/apps/web/src/api/generated/TopicRef.ts b/apps/web/src/api/generated/TopicRef.ts new file mode 100644 index 0000000..68dd4d9 --- /dev/null +++ b/apps/web/src/api/generated/TopicRef.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type TopicRef = { topic_id: string, label: string, }; diff --git a/apps/web/src/api/generated/TopicRename.ts b/apps/web/src/api/generated/TopicRename.ts new file mode 100644 index 0000000..e96b6fa --- /dev/null +++ b/apps/web/src/api/generated/TopicRename.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type TopicRename = { label: string, }; diff --git a/apps/web/src/api/generated/WeekCount.ts b/apps/web/src/api/generated/WeekCount.ts new file mode 100644 index 0000000..e294b98 --- /dev/null +++ b/apps/web/src/api/generated/WeekCount.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type WeekCount = { week: string, notes: number, }; diff --git a/apps/web/src/api/queries.ts b/apps/web/src/api/queries.ts new file mode 100644 index 0000000..ac4e08a --- /dev/null +++ b/apps/web/src/api/queries.ts @@ -0,0 +1,54 @@ +import { QueryClient, queryOptions, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useEffect } from "react"; +import { ApiError } from "./bridge"; +import { api } from "./client"; +import type { NotesQuery } from "./generated/NotesQuery"; + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + // Freshness comes from the server's change events, not from polling. + staleTime: Number.POSITIVE_INFINITY, + retry: (count, error) => !(error instanceof ApiError && error.status < 500) && count < 2, + }, + }, +}); + +export const q = { + session: () => queryOptions({ queryKey: ["session"], queryFn: api.session }), + notes: (query: NotesQuery) => + queryOptions({ queryKey: ["notes", query], queryFn: () => api.notes(query) }), + note: (id: string) => queryOptions({ queryKey: ["note", id], queryFn: () => api.note(id) }), + topics: () => queryOptions({ queryKey: ["topics"], queryFn: api.topics }), + topic: (id: string) => queryOptions({ queryKey: ["topic", id], queryFn: () => api.topic(id) }), + tags: () => queryOptions({ queryKey: ["tags"], queryFn: api.tags }), + stats: () => queryOptions({ queryKey: ["stats"], queryFn: api.stats }), + problems: () => queryOptions({ queryKey: ["problems"], queryFn: api.problems }), +}; + +/** Refetches everything on screen whenever the server reports a vault change. */ +export function useVaultEvents(enabled: boolean): void { + const client = useQueryClient(); + useEffect(() => { + if (!enabled) { + return; + } + const events = new EventSource("/api/events"); + const refresh = () => { + void client.invalidateQueries({ predicate: (query) => query.queryKey[0] !== "session" }); + }; + events.addEventListener("changed", refresh); + // After a reconnect (server restarted), anything may have changed. + events.addEventListener("open", refresh); + return () => events.close(); + }, [client, enabled]); +} + +/** A mutation that refreshes every query when it succeeds. */ +export function useEdit<A, R>(fn: (args: A) => Promise<R>) { + const client = useQueryClient(); + return useMutation({ + mutationFn: fn, + onSuccess: () => client.invalidateQueries(), + }); +} diff --git a/apps/web/src/components/Annotations.tsx b/apps/web/src/components/Annotations.tsx new file mode 100644 index 0000000..566844f --- /dev/null +++ b/apps/web/src/components/Annotations.tsx @@ -0,0 +1,156 @@ +import { Pencil, Trash2 } from "lucide-react"; +import { useState } from "react"; +import { api } from "../api/client"; +import type { AnnotationView } from "../api/generated/AnnotationView"; +import { useEdit } from "../api/queries"; +import { formatDateTime } from "../lib/format"; +import { Markdown } from "./Markdown"; +import { Button, ErrorBox } from "./ui"; + +/** The user's own understanding of a note, one entry per annotation file. */ +export function Annotations({ + noteId, + annotations, +}: { + noteId: string; + annotations: AnnotationView[]; +}) { + const add = useEdit((text: string) => api.addAnnotation(noteId, { text })); + return ( + <div className="space-y-4"> + {annotations.length === 0 && ( + <p className="text-sm text-ink-faint"> + 还没有 annotation。用自己的话写下你现在的理解,下次重问时 agent 会先看到它。 + </p> + )} + {annotations.map((a) => ( + <AnnotationItem key={a.id} annotation={a} /> + ))} + <Editor + key={annotations.length} + submitLabel="添加" + pending={add.isPending} + error={add.error} + onSubmit={(text) => add.mutate(text)} + /> + </div> + ); +} + +function AnnotationItem({ annotation }: { annotation: AnnotationView }) { + const [editing, setEditing] = useState(false); + const update = useEdit((text: string) => api.updateAnnotation(annotation.id, { text })); + const remove = useEdit(() => api.deleteAnnotation(annotation.id)); + + if (editing) { + return ( + <Editor + initial={annotation.body} + submitLabel="保存" + pending={update.isPending} + error={update.error} + onCancel={() => setEditing(false)} + onSubmit={(text) => update.mutate(text, { onSuccess: () => setEditing(false) })} + /> + ); + } + return ( + <div className="rounded-panel border-l-2 border-brand bg-panel py-3 pr-2 pl-4"> + <Markdown source={annotation.body} /> + <div className="mt-2 flex items-center gap-1 text-xs text-ink-faint"> + <span> + {formatDateTime(annotation.created)} + {annotation.updated && ` · 改于 ${formatDateTime(annotation.updated)}`} + </span> + <span className="ml-auto" /> + <IconButton label="编辑" onClick={() => setEditing(true)}> + <Pencil className="size-3.5" /> + </IconButton> + <IconButton + label="删除" + disabled={remove.isPending} + onClick={() => { + if (window.confirm("删除这条 annotation?文件会从 vault 里移除。")) { + remove.mutate(undefined); + } + }} + > + <Trash2 className="size-3.5" /> + </IconButton> + </div> + {remove.error && <ErrorBox error={remove.error} />} + </div> + ); +} + +function Editor({ + initial = "", + submitLabel, + pending, + error, + onSubmit, + onCancel, +}: { + initial?: string; + submitLabel: string; + pending: boolean; + error: Error | null; + onSubmit: (text: string) => void; + onCancel?: () => void; +}) { + const [text, setText] = useState(initial); + const submit = () => { + if (text.trim()) { + onSubmit(text); + } + }; + return ( + <form + className="space-y-2" + onSubmit={(event) => { + event.preventDefault(); + submit(); + }} + > + <textarea + value={text} + onChange={(event) => setText(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + submit(); + } + }} + rows={4} + placeholder="我的理解…(支持 Markdown,⌘Enter 保存)" + aria-label="Annotation" + className="w-full resize-y rounded-panel border border-line-strong bg-panel px-3 py-2 text-[0.9375rem] text-ink placeholder:text-ink-faint focus:border-brand focus:outline-none" + /> + {error && <ErrorBox error={error} />} + <div className="flex gap-2"> + <Button type="submit" variant="primary" disabled={pending || !text.trim()}> + {submitLabel} + </Button> + {onCancel && <Button onClick={onCancel}>取消</Button>} + </div> + </form> + ); +} + +function IconButton({ + label, + children, + ...props +}: React.ButtonHTMLAttributes<HTMLButtonElement> & { label: string }) { + return ( + <button + type="button" + aria-label={label} + title={label} + className="inline-flex size-7 items-center justify-center rounded-[1px] transition-colors duration-150 hover:bg-panel-hover hover:text-ink disabled:opacity-50" + {...props} + > + {children} + </button> + ); +} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx new file mode 100644 index 0000000..d406c2d --- /dev/null +++ b/apps/web/src/components/CommandPalette.tsx @@ -0,0 +1,129 @@ +import { useQuery } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { Command } from "cmdk"; +import { FileText, Hash, MessagesSquare, Search } from "lucide-react"; +import { useDeferredValue, useEffect, useState } from "react"; +import { q } from "../api/queries"; + +const MAX_TOPICS = 6; + +/** ⌘K / Ctrl+K: search notes by keyword, jump to a topic or tag. */ +export function CommandPalette({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const [input, setInput] = useState(""); + const text = useDeferredValue(input.trim()); + const navigate = useNavigate(); + const notes = useQuery({ ...q.notes({ q: text, limit: 20 }), enabled: open }); + const topics = useQuery({ ...q.topics(), enabled: open }); + const tags = useQuery({ ...q.tags(), enabled: open }); + + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (event.key === "k" && (event.metaKey || event.ctrlKey)) { + event.preventDefault(); + onOpenChange(!open); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, onOpenChange]); + + const needle = text.toLowerCase(); + const topicHits = needle + ? (topics.data ?? []).filter((t) => t.label.toLowerCase().includes(needle)).slice(0, MAX_TOPICS) + : []; + const tagHits = needle ? (tags.data ?? []).filter((t) => t.tag.includes(needle)).slice(0, 6) : []; + + const go = (action: () => void) => { + onOpenChange(false); + setInput(""); + action(); + }; + + return ( + <Command.Dialog + open={open} + onOpenChange={onOpenChange} + shouldFilter={false} + label="搜索" + overlayClassName="fixed inset-0 z-40 bg-ink/25" + contentClassName="fixed inset-x-4 top-[12vh] z-50 mx-auto max-w-xl rounded-panel border border-line-strong bg-panel text-ink" + > + <div className="flex items-center gap-2 border-b border-line px-3"> + <Search className="size-4 text-ink-faint" aria-hidden /> + <Command.Input + value={input} + onValueChange={setInput} + placeholder="搜索 note、topic、tag…" + className="h-12 w-full bg-transparent text-base outline-none placeholder:text-ink-faint" + /> + </div> + <Command.List className="max-h-[60vh] overflow-y-auto p-1.5"> + <Command.Empty className="px-3 py-6 text-center text-sm text-ink-faint"> + {notes.isFetching ? "搜索中…" : "没有匹配的 note"} + </Command.Empty> + {topicHits.length > 0 && ( + <Command.Group heading="Topic" className={GROUP}> + {topicHits.map((t) => ( + <Command.Item + key={t.topic_id} + value={`topic:${t.topic_id}`} + className={ITEM} + onSelect={() => + go(() => navigate({ to: "/topics/$topicId", params: { topicId: t.topic_id } })) + } + > + <MessagesSquare className="size-4 shrink-0 text-ink-faint" aria-hidden /> + <span className="truncate">{t.label}</span> + <span className="ml-auto text-xs text-ink-faint">{t.ask_count} 次</span> + </Command.Item> + ))} + </Command.Group> + )} + {tagHits.length > 0 && ( + <Command.Group heading="Tag" className={GROUP}> + {tagHits.map((t) => ( + <Command.Item + key={t.tag} + value={`tag:${t.tag}`} + className={ITEM} + onSelect={() => go(() => navigate({ to: "/tags/$tag", params: { tag: t.tag } }))} + > + <Hash className="size-4 shrink-0 text-ink-faint" aria-hidden /> + {t.tag} + <span className="ml-auto text-xs text-ink-faint">{t.notes}</span> + </Command.Item> + ))} + </Command.Group> + )} + {(notes.data ?? []).length > 0 && ( + <Command.Group heading={text ? "Note" : "最近的 note"} className={GROUP}> + {(notes.data ?? []).map((n) => ( + <Command.Item + key={n.id} + value={`note:${n.id}`} + className={ITEM} + onSelect={() => + go(() => navigate({ to: "/notes/$noteId", params: { noteId: n.id } })) + } + > + <FileText className="size-4 shrink-0 text-ink-faint" aria-hidden /> + <span className="truncate">{n.title}</span> + </Command.Item> + ))} + </Command.Group> + )} + </Command.List> + </Command.Dialog> + ); +} + +const GROUP = + "[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:pt-2 [&_[cmdk-group-heading]]:pb-1 [&_[cmdk-group-heading]]:text-[11px] [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-wider [&_[cmdk-group-heading]]:text-ink-faint"; +const ITEM = + "flex cursor-pointer items-center gap-2 rounded-[1px] px-2 py-2 text-sm data-[selected=true]:bg-brand-soft data-[selected=true]:text-brand-ink"; diff --git a/apps/web/src/components/Layout.tsx b/apps/web/src/components/Layout.tsx new file mode 100644 index 0000000..55ec13d --- /dev/null +++ b/apps/web/src/components/Layout.tsx @@ -0,0 +1,132 @@ +import { useQuery } from "@tanstack/react-query"; +import { Link, Outlet } from "@tanstack/react-router"; +import { Search } from "lucide-react"; +import { type ReactNode, useState } from "react"; +import { ApiError } from "../api/bridge"; +import { q, useVaultEvents } from "../api/queries"; +import { CommandPalette } from "./CommandPalette"; +import { ThemeToggle } from "./ThemeToggle"; +import { ErrorBox, Loading } from "./ui"; + +export function Layout() { + const session = useQuery(q.session()); + const [paletteOpen, setPaletteOpen] = useState(false); + useVaultEvents(session.isSuccess); + + if (session.error instanceof ApiError && session.error.status === 401) { + return <AuthRequired />; + } + return ( + <div className="min-h-screen lg:grid lg:grid-cols-[15rem_minmax(0,1fr)]"> + <Sidebar onSearch={() => setPaletteOpen(true)} /> + <main className="min-w-0 px-4 pt-6 pb-24 sm:px-8 lg:px-12 lg:pt-12"> + <div className="mx-auto max-w-[52rem]"> + {session.isPending ? ( + <Loading /> + ) : session.isError ? ( + <ErrorBox error={session.error} /> + ) : ( + <Outlet /> + )} + </div> + </main> + {session.isSuccess && <CommandPalette open={paletteOpen} onOpenChange={setPaletteOpen} />} + </div> + ); +} + +function Sidebar({ onSearch }: { onSearch: () => void }) { + const problems = useQuery(q.problems()); + const problemCount = + (problems.data?.conflicts.length ?? 0) + (problems.data?.invalid_files.length ?? 0); + return ( + <aside className="border-b border-line bg-panel lg:sticky lg:top-0 lg:h-screen lg:border-r lg:border-b-0"> + <div className="flex items-center justify-between gap-2 px-4 py-3 lg:px-5 lg:pt-6 lg:pb-5"> + <Link to="/" className="flex items-center gap-2 font-display text-xl text-ink"> + <span className="inline-block size-3 bg-brand" aria-hidden /> + Distill + </Link> + <div className="flex items-center gap-2 lg:hidden"> + <ThemeToggle /> + <button + type="button" + onClick={onSearch} + className="inline-flex h-8 items-center gap-2 rounded-[1px] border border-line px-2 text-sm text-ink-faint transition-colors duration-150 hover:border-line-strong hover:text-ink" + aria-label="搜索" + > + <Search className="size-4" aria-hidden /> + </button> + </div> + </div> + <button + type="button" + onClick={onSearch} + className="mx-5 mb-4 hidden h-9 w-[calc(100%-2.5rem)] items-center gap-2 rounded-[1px] border border-line px-2.5 text-sm text-ink-faint transition-colors duration-150 hover:border-line-strong hover:text-ink lg:flex" + > + <Search className="size-4" aria-hidden /> + 搜索 + <kbd className="ml-auto font-mono text-xs">⌘K</kbd> + </button> + <nav + aria-label="主导航" + className="flex gap-1 overflow-x-auto px-3 pb-2 lg:flex-col lg:gap-0.5 lg:px-4 lg:pb-0" + > + <NavItem to="/">首页</NavItem> + <NavItem to="/notes">Notes</NavItem> + <NavItem to="/topics">Topics</NavItem> + <NavItem to="/tags">Tags</NavItem> + <NavItem to="/stats">统计</NavItem> + {problemCount > 0 && ( + <NavItem to="/problems"> + 需要处理 + <span className="ml-1.5 rounded-[1px] bg-error px-1 text-xs text-on-brand tabular-nums"> + {problemCount} + </span> + </NavItem> + )} + </nav> + <div className="hidden px-4 lg:absolute lg:bottom-4 lg:block"> + <ThemeToggle /> + </div> + </aside> + ); +} + +function NavItem({ to, children }: { to: string; children: ReactNode }) { + return ( + <Link + to={to} + activeOptions={{ exact: to === "/" }} + className="flex shrink-0 items-center py-1 text-sm lg:py-0.5" + > + {({ isActive }) => ( + <span + className={ + isActive + ? "rounded-[1px] bg-brand-strong px-2 py-1 text-on-brand" + : "rounded-[1px] px-2 py-1 text-ink-muted transition-colors duration-150 hover:text-ink" + } + > + {children} + </span> + )} + </Link> + ); +} + +function AuthRequired() { + return ( + <div className="flex min-h-screen items-center justify-center px-4"> + <div className="max-w-md rounded-panel border border-line bg-panel p-8"> + <h1 className="text-2xl">这个浏览器还没有授权</h1> + <p className="mt-4">在终端运行下面的命令,它会打开一个已授权的页面:</p> + <pre className="mt-3 border border-line bg-card px-3 py-2 font-mono text-sm text-ink"> + distill ui + </pre> + <p className="mt-4 text-sm text-ink-faint"> + 授权只需一次,之后这个浏览器打开 Distill 的链接都能直接看到内容。 + </p> + </div> + </div> + ); +} diff --git a/apps/web/src/components/Markdown.tsx b/apps/web/src/components/Markdown.tsx new file mode 100644 index 0000000..36e4a34 --- /dev/null +++ b/apps/web/src/components/Markdown.tsx @@ -0,0 +1,11 @@ +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; + +/** Note and annotation Markdown. Raw HTML in the source is shown as text, never rendered. */ +export function Markdown({ source }: { source: string }) { + return ( + <div className="prose-note"> + <ReactMarkdown remarkPlugins={[remarkGfm]}>{source}</ReactMarkdown> + </div> + ); +} diff --git a/apps/web/src/components/NoteList.tsx b/apps/web/src/components/NoteList.tsx new file mode 100644 index 0000000..e167b54 --- /dev/null +++ b/apps/web/src/components/NoteList.tsx @@ -0,0 +1,50 @@ +import { Link } from "@tanstack/react-router"; +import { WindowVirtualizer } from "virtua"; +import type { NoteHit } from "../api/generated/NoteHit"; +import { excerpt, formatDate } from "../lib/format"; +import { Empty, TagChip } from "./ui"; + +/** Notes as rows; long lists render only what is on screen. */ +export function NoteList({ notes, empty }: { notes: NoteHit[]; empty: string }) { + if (notes.length === 0) { + return <Empty>{empty}</Empty>; + } + return ( + <ol className="border-t border-line"> + <WindowVirtualizer> + {notes.map((note) => ( + <li key={note.id} className="border-b border-line"> + <NoteRow note={note} /> + </li> + ))} + </WindowVirtualizer> + </ol> + ); +} + +function NoteRow({ note }: { note: NoteHit }) { + return ( + <div className="group relative px-1 py-3 transition-colors duration-150 hover:bg-panel-hover sm:px-3"> + <div className="flex items-baseline justify-between gap-4"> + <Link + to="/notes/$noteId" + params={{ noteId: note.id }} + className="font-display text-[1.0625rem] leading-snug text-ink after:absolute after:inset-0 group-hover:text-brand-ink" + > + {note.title} + </Link> + <time className="shrink-0 text-xs text-ink-faint tabular-nums" dateTime={note.created}> + {formatDate(note.created)} + </time> + </div> + <p className="mt-1 line-clamp-2 text-sm text-ink-muted">{excerpt(note.conclusion)}</p> + {note.tags.length > 0 && ( + <div className="relative z-10 mt-2 flex flex-wrap gap-1.5"> + {note.tags.map((tag) => ( + <TagChip key={tag} tag={tag} /> + ))} + </div> + )} + </div> + ); +} diff --git a/apps/web/src/components/ThemeToggle.tsx b/apps/web/src/components/ThemeToggle.tsx new file mode 100644 index 0000000..8f0f757 --- /dev/null +++ b/apps/web/src/components/ThemeToggle.tsx @@ -0,0 +1,69 @@ +import { Monitor, Moon, Sun } from "lucide-react"; +import { useEffect, useState } from "react"; +import { cx } from "./ui"; + +type Theme = "system" | "light" | "dark"; +const KEY = "distill-theme"; + +function stored(): Theme { + const value = localStorage.getItem(KEY); + return value === "light" || value === "dark" ? value : "system"; +} + +/** Sets `data-theme` on <html>, following the system setting unless the user picked one. */ +export function applyTheme(theme: Theme = stored()): void { + const dark = + theme === "dark" || + (theme === "system" && window.matchMedia("(prefers-color-scheme: dark)").matches); + document.documentElement.dataset.theme = dark ? "dark" : "light"; +} + +const OPTIONS: { value: Theme; label: string; Icon: typeof Sun }[] = [ + { value: "light", label: "浅色", Icon: Sun }, + { value: "system", label: "跟随系统", Icon: Monitor }, + { value: "dark", label: "深色", Icon: Moon }, +]; + +export function ThemeToggle() { + const [theme, setTheme] = useState<Theme>(stored); + + useEffect(() => { + applyTheme(theme); + if (theme !== "system") { + return; + } + const media = window.matchMedia("(prefers-color-scheme: dark)"); + const onChange = () => applyTheme("system"); + media.addEventListener("change", onChange); + return () => media.removeEventListener("change", onChange); + }, [theme]); + + return ( + <fieldset className="flex gap-0.5"> + <legend className="sr-only">主题</legend> + {OPTIONS.map(({ value, label, Icon }) => ( + <button + key={value} + type="button" + aria-pressed={theme === value} + title={label} + aria-label={label} + onClick={() => { + if (value === "system") { + localStorage.removeItem(KEY); + } else { + localStorage.setItem(KEY, value); + } + setTheme(value); + }} + className={cx( + "inline-flex size-8 items-center justify-center rounded-[1px] transition-colors duration-150", + theme === value ? "bg-card text-ink" : "text-ink-faint hover:text-ink", + )} + > + <Icon className="size-4" aria-hidden /> + </button> + ))} + </fieldset> + ); +} diff --git a/apps/web/src/components/TopicPicker.tsx b/apps/web/src/components/TopicPicker.tsx new file mode 100644 index 0000000..8e85296 --- /dev/null +++ b/apps/web/src/components/TopicPicker.tsx @@ -0,0 +1,53 @@ +import { useQuery } from "@tanstack/react-query"; +import { Command } from "cmdk"; +import type { TopicCount } from "../api/generated/TopicCount"; +import { q } from "../api/queries"; +import { Button, ErrorBox, Loading } from "./ui"; + +/** Choose a topic by typing part of its label. */ +export function TopicPicker({ + exclude, + onPick, + onCancel, +}: { + exclude: string; + onPick: (topic: TopicCount) => void; + onCancel: () => void; +}) { + const topics = useQuery(q.topics()); + if (topics.isPending) { + return <Loading />; + } + if (topics.isError) { + return <ErrorBox error={topics.error} />; + } + const candidates = topics.data.filter((t) => t.topic_id !== exclude); + return ( + <div className="rounded-panel border border-line-strong bg-panel"> + <Command label="选择要合并进的 topic"> + <Command.Input + autoFocus + placeholder="输入 topic 名称…" + className="h-10 w-full border-b border-line bg-transparent px-3 text-sm text-ink outline-none placeholder:text-ink-faint" + /> + <Command.List className="max-h-72 overflow-y-auto p-1"> + <Command.Empty className="px-3 py-4 text-sm text-ink-faint">没有其他 topic</Command.Empty> + {candidates.map((t) => ( + <Command.Item + key={t.topic_id} + value={`${t.label} ${t.topic_id}`} + onSelect={() => onPick(t)} + className="flex cursor-pointer items-center gap-2 rounded-[1px] px-2 py-2 text-sm data-[selected=true]:bg-brand-soft data-[selected=true]:text-brand-ink" + > + <span className="truncate">{t.label}</span> + <span className="ml-auto shrink-0 text-xs text-ink-faint">{t.ask_count} 次</span> + </Command.Item> + ))} + </Command.List> + </Command> + <div className="border-t border-line p-2"> + <Button onClick={onCancel}>取消</Button> + </div> + </div> + ); +} diff --git a/apps/web/src/components/TopicRows.tsx b/apps/web/src/components/TopicRows.tsx new file mode 100644 index 0000000..fbdb39a --- /dev/null +++ b/apps/web/src/components/TopicRows.tsx @@ -0,0 +1,36 @@ +import { Link } from "@tanstack/react-router"; +import type { TopicCount } from "../api/generated/TopicCount"; +import { formatDate } from "../lib/format"; +import { AskCount, Empty } from "./ui"; + +export function TopicRows({ topics }: { topics: TopicCount[] }) { + if (topics.length === 0) { + return <Empty>没有 topic。</Empty>; + } + return ( + <ul className="border-t border-line"> + {topics.map((t) => ( + <li key={t.topic_id} className="border-b border-line"> + <Link + to="/topics/$topicId" + params={{ topicId: t.topic_id }} + className="group flex items-baseline gap-3 px-1 py-2.5 transition-colors duration-150 hover:bg-panel-hover sm:px-3" + > + <span className="min-w-0 flex-1 truncate text-ink group-hover:text-brand-ink"> + {t.label} + </span> + {!t.annotated && t.ask_count >= 2 && ( + <span className="hidden shrink-0 text-xs text-ink-faint sm:inline"> + 无 annotation + </span> + )} + <span className="hidden shrink-0 text-xs text-ink-faint tabular-nums sm:inline"> + {formatDate(t.last_asked)} + </span> + <AskCount count={t.ask_count} /> + </Link> + </li> + ))} + </ul> + ); +} diff --git a/apps/web/src/components/charts.tsx b/apps/web/src/components/charts.tsx new file mode 100644 index 0000000..09924ed --- /dev/null +++ b/apps/web/src/components/charts.tsx @@ -0,0 +1,180 @@ +import { Link } from "@tanstack/react-router"; +import { useState } from "react"; +import { cx } from "./ui"; + +export type Column = { key: string; label: string; value: number }; + +const PLOT_HEIGHT = 160; + +/** + * One series of columns (notes per week). Single hue, no legend: the section title names + * it. Each column is its own hover and focus target; the peak carries a direct label and + * every value is also in the table below the chart. + */ +export function Columns({ columns, unit }: { columns: Column[]; unit: string }) { + const [active, setActive] = useState<number | null>(null); + const max = Math.max(1, ...columns.map((c) => c.value)); + const peak = columns.reduce( + (best, c, i) => (c.value > (columns[best]?.value ?? 0) ? i : best), + 0, + ); + const tick = Math.max(1, Math.ceil(columns.length / 6)); + + return ( + <figure> + <div className="relative" style={{ height: PLOT_HEIGHT + 24 }}> + <div className="absolute inset-x-0 top-0 border-t border-line" style={{ top: 20 }} /> + <div + className="absolute inset-x-0 border-t border-line-strong" + style={{ top: PLOT_HEIGHT + 20 }} + /> + <div className="absolute inset-x-0 flex items-end" style={{ top: 20, height: PLOT_HEIGHT }}> + {columns.map((c, i) => { + const height = (c.value / max) * PLOT_HEIGHT; + return ( + <button + key={c.key} + type="button" + className="group relative flex h-full flex-1 items-end justify-center px-px outline-none" + aria-label={`${c.label}:${c.value} ${unit}`} + onPointerEnter={() => setActive(i)} + onPointerLeave={() => setActive(null)} + onFocus={() => setActive(i)} + onBlur={() => setActive(null)} + > + <span + className={cx( + "block w-full max-w-6 rounded-t-[2px] transition-colors duration-150", + c.value > 0 ? "bg-brand" : "", + active === i && "bg-brand-strong", + "group-focus-visible:outline-2 group-focus-visible:outline-offset-2 group-focus-visible:outline-focus", + )} + style={{ height: c.value > 0 ? Math.max(height, 2) : 0 }} + /> + {i === peak && c.value > 0 && active === null && ( + <span + className="absolute text-xs text-ink tabular-nums" + style={{ bottom: height + 4 }} + > + {c.value} + </span> + )} + {active === i && ( + <span + className="pointer-events-none absolute z-10 rounded-panel border border-line-strong bg-panel px-2 py-1 text-left whitespace-nowrap" + style={{ bottom: height + 6 }} + > + <span className="block text-sm font-bold text-ink tabular-nums"> + {c.value} {unit} + </span> + <span className="block text-xs text-ink-faint">{c.label}</span> + </span> + )} + </button> + ); + })} + </div> + <div className="absolute inset-x-0 flex" style={{ top: PLOT_HEIGHT + 24 }}> + {columns.map((c, i) => ( + <span key={c.key} className="flex-1 text-center text-[11px] text-ink-faint"> + {i % tick === 0 || i === columns.length - 1 ? c.label : ""} + </span> + ))} + </div> + </div> + <DataTable + head={["周", unit]} + rows={columns.map((c) => [c.label, c.value])} + className="mt-8" + /> + </figure> + ); +} + +export type Bar = { key: string; label: string; value: number }; + +/** Horizontal bars for a ranking (tags, projects), value at the tip. */ +export function BarList({ + bars, + link, +}: { + bars: Bar[]; + link?: (bar: Bar) => { to: string; params?: Record<string, string> }; +}) { + const max = Math.max(1, ...bars.map((b) => b.value)); + return ( + <ul className="space-y-1"> + {bars.map((b) => { + const target = link?.(b); + const label = ( + <span className="block truncate text-sm text-ink" title={b.label}> + {b.label} + </span> + ); + return ( + <li + key={b.key} + className="grid grid-cols-[minmax(0,10rem)_minmax(0,1fr)] items-center gap-3" + > + {target ? ( + <Link + to={target.to} + params={target.params} + className="min-w-0 hover:[&>span]:text-brand-ink" + > + {label} + </Link> + ) : ( + label + )} + <span className="flex items-center gap-2"> + <span + className="block h-3 rounded-r-[2px] bg-brand" + style={{ width: `calc(${(b.value / max) * 100}% - 2.5rem)` }} + /> + <span className="text-xs text-ink-muted tabular-nums">{b.value}</span> + </span> + </li> + ); + })} + </ul> + ); +} + +function DataTable({ + head, + rows, + className, +}: { + head: string[]; + rows: (string | number)[][]; + className?: string; +}) { + return ( + <details className={cx("text-sm", className)}> + <summary className="cursor-pointer text-ink-faint hover:text-ink">数据表</summary> + <table className="mt-2 w-full max-w-sm"> + <thead> + <tr className="border-b border-line text-left text-xs text-ink-faint"> + {head.map((h) => ( + <th key={h} className="py-1 font-normal"> + {h} + </th> + ))} + </tr> + </thead> + <tbody> + {rows.map((row) => ( + <tr key={String(row[0])} className="border-b border-line"> + {row.map((cell, i) => ( + <td key={head[i]} className={cx("py-1", i > 0 && "tabular-nums")}> + {cell} + </td> + ))} + </tr> + ))} + </tbody> + </table> + </details> + ); +} diff --git a/apps/web/src/components/ui.tsx b/apps/web/src/components/ui.tsx new file mode 100644 index 0000000..8dfdced --- /dev/null +++ b/apps/web/src/components/ui.tsx @@ -0,0 +1,165 @@ +import { Link } from "@tanstack/react-router"; +import { Check, Copy, LoaderCircle } from "lucide-react"; +import { type ButtonHTMLAttributes, type ReactNode, useState } from "react"; +import { ApiError } from "../api/bridge"; + +export function cx(...parts: (string | false | null | undefined)[]): string { + return parts.filter(Boolean).join(" "); +} + +type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & { + variant?: "primary" | "quiet" | "danger"; +}; + +export function Button({ variant = "quiet", className, ...props }: ButtonProps) { + return ( + <button + type="button" + className={cx( + "inline-flex min-h-8 items-center gap-1.5 rounded-[1px] border px-3 text-sm transition-colors duration-150 disabled:cursor-not-allowed disabled:opacity-50", + variant === "primary" && + "border-brand-strong bg-brand-strong text-on-brand hover:bg-brand-ink hover:border-brand-ink", + variant === "quiet" && + "border-line-strong bg-panel text-ink hover:bg-panel-hover hover:border-ink-faint", + variant === "danger" && + "border-line-strong bg-panel text-error hover:border-error hover:bg-error-soft", + className, + )} + {...props} + /> + ); +} + +export function PageHeader({ + title, + eyebrow, + children, +}: { + title: ReactNode; + eyebrow?: ReactNode; + children?: ReactNode; +}) { + return ( + <header className="mb-8"> + {eyebrow && <div className="mb-2 text-sm text-ink-faint">{eyebrow}</div>} + <h1 className="text-[1.875rem] leading-tight sm:text-4xl">{title}</h1> + {children && <div className="mt-3 text-ink-muted">{children}</div>} + </header> + ); +} + +export function Section({ + title, + action, + children, +}: { + title: ReactNode; + action?: ReactNode; + children: ReactNode; +}) { + return ( + <section className="mt-10 first:mt-0"> + <div className="mb-4 flex items-baseline justify-between gap-4 border-b border-line pb-2"> + <h2 className="text-xl">{title}</h2> + {action} + </div> + {children} + </section> + ); +} + +export function Panel({ className, children }: { className?: string; children: ReactNode }) { + return ( + <div className={cx("rounded-panel border border-line bg-panel", className)}>{children}</div> + ); +} + +export function Empty({ children }: { children: ReactNode }) { + return ( + <p className="rounded-panel border border-dashed border-line px-4 py-6 text-center text-sm text-ink-faint"> + {children} + </p> + ); +} + +export function Loading() { + return ( + <div className="flex items-center gap-2 py-10 text-sm text-ink-faint" role="status"> + <LoaderCircle className="size-4 animate-spin" aria-hidden /> + 载入中… + </div> + ); +} + +export function ErrorBox({ error }: { error: unknown }) { + const message = error instanceof Error ? error.message : String(error); + const notFound = error instanceof ApiError && error.status === 404; + return ( + <div + role="alert" + className="rounded-panel border border-error/40 bg-error-soft px-4 py-3 text-sm text-error" + > + {notFound ? "没有找到:" : "出错了:"} + {message} + </div> + ); +} + +/** How many times a topic was asked. Two or more is the signal Distill exists for. */ +export function AskCount({ count, className }: { count: number; className?: string }) { + const repeated = count >= 2; + return ( + <span + className={cx( + "inline-flex shrink-0 items-center rounded-[1px] px-1.5 py-px text-xs tabular-nums", + repeated ? "bg-warning-soft text-warning" : "text-ink-faint", + className, + )} + title={`问了 ${count} 次`} + > + {repeated ? `问了 ${count} 次` : "1 次"} + </span> + ); +} + +export function TagChip({ tag }: { tag: string }) { + return ( + <Link + to="/tags/$tag" + params={{ tag }} + className="inline-flex items-center rounded-[1px] border border-line px-1.5 py-px text-xs text-ink-muted transition-colors duration-150 hover:border-brand hover:text-brand-ink" + > + #{tag} + </Link> + ); +} + +export function CopyButton({ text, label }: { text: string; label: string }) { + const [copied, setCopied] = useState(false); + return ( + <button + type="button" + aria-label={label} + title={label} + className="inline-flex size-8 shrink-0 items-center justify-center rounded-[1px] text-ink-faint transition-colors duration-150 hover:bg-panel-hover hover:text-ink" + onClick={() => { + void navigator.clipboard.writeText(text).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }); + }} + > + {copied ? <Check className="size-4 text-success" /> : <Copy className="size-4" />} + </button> + ); +} + +export function Stat({ label, value, hint }: { label: string; value: ReactNode; hint?: string }) { + return ( + <div className="rounded-panel border border-line bg-panel px-4 py-3"> + <div className="text-xs uppercase tracking-wider text-ink-faint">{label}</div> + <div className="mt-1 text-3xl font-bold text-ink">{value}</div> + {hint && <div className="mt-1 text-xs text-ink-faint">{hint}</div>} + </div> + ); +} diff --git a/apps/web/src/lib/format.test.ts b/apps/web/src/lib/format.test.ts new file mode 100644 index 0000000..32e3b4a --- /dev/null +++ b/apps/web/src/lib/format.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { excerpt, formatAgo, projectName, stripTitle } from "./format"; + +describe("formatAgo", () => { + const now = new Date("2026-09-20T12:00:00Z"); + it("picks the largest unit", () => { + expect(formatAgo("2026-09-17T12:00:00Z", now)).toBe("3天前"); + expect(formatAgo("2026-09-20T11:59:30Z", now)).toBe("刚刚"); + expect(formatAgo("2026-09-06T12:00:00Z", now)).toBe("2周前"); + }); +}); + +describe("projectName", () => { + it("prefers the git remote", () => { + expect(projectName({ git_repo: "https://github.com/me/app.git", cwd: "/x" })).toBe( + "github.com/me/app", + ); + }); + it("falls back to the tail of cwd", () => { + expect(projectName({ cwd: "/Users/me/code/distill" })).toBe("code/distill"); + expect(projectName({ cwd: "C:\\work\\app" })).toBe("work/app"); + }); +}); + +describe("stripTitle", () => { + it("drops only the first H1", () => { + expect(stripTitle("# Title\n\n## 问题\n\n# not a title")).toBe("\n## 问题\n\n# not a title"); + }); +}); + +describe("excerpt", () => { + it("keeps prose and drops code and markers", () => { + expect( + excerpt("不能直接用:`bun:sqlite` **很慢**。\n\n```ts\nx()\n```\n- [文档](http://a)"), + ).toBe("不能直接用:bun:sqlite 很慢。 文档"); + }); +}); diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts new file mode 100644 index 0000000..a5bc541 --- /dev/null +++ b/apps/web/src/lib/format.ts @@ -0,0 +1,75 @@ +import type { Agent } from "../api/generated/Agent"; +import type { Source } from "../api/generated/Source"; + +const dateFormat = new Intl.DateTimeFormat("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", +}); +const dateTimeFormat = new Intl.DateTimeFormat("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", +}); +const relative = new Intl.RelativeTimeFormat("zh-CN", { numeric: "auto" }); + +/** `2026-09-13` from an RFC 3339 timestamp, in the viewer's time zone. */ +export function formatDate(rfc3339: string): string { + return dateFormat.format(new Date(rfc3339)); +} + +export function formatDateTime(rfc3339: string): string { + return dateTimeFormat.format(new Date(rfc3339)); +} + +const UNITS: [Intl.RelativeTimeFormatUnit, number][] = [ + ["year", 365 * 24 * 3600], + ["month", 30 * 24 * 3600], + ["week", 7 * 24 * 3600], + ["day", 24 * 3600], + ["hour", 3600], + ["minute", 60], +]; + +/** "3 天前"; under a minute is "刚刚". */ +export function formatAgo(rfc3339: string, now: Date = new Date()): string { + const seconds = (new Date(rfc3339).getTime() - now.getTime()) / 1000; + for (const [unit, size] of UNITS) { + if (Math.abs(seconds) >= size) { + return relative.format(Math.round(seconds / size), unit); + } + } + return "刚刚"; +} + +export const agentLabel: Record<Agent, string> = { + codex: "Codex", + "claude-code": "Claude Code", +}; + +/** The project a note came from: the git remote when known, else the last path segments. */ +export function projectName(source: Pick<Source, "git_repo" | "cwd">): string { + if (source.git_repo) { + return source.git_repo.replace(/^https?:\/\//, "").replace(/\.git$/, ""); + } + const parts = source.cwd.split(/[\\/]/).filter(Boolean); + return parts.slice(-2).join("/") || source.cwd; +} + +/** The body of a note without its leading `# title` line, which the page renders itself. */ +export function stripTitle(markdown: string): string { + return markdown.replace(/^\s*# [^\n]*\n?/, ""); +} + +/** Plain text for a one- or two-line preview: code blocks and Markdown markers removed. */ +export function excerpt(markdown: string): string { + return markdown + .replace(/```[\s\S]*?(```|$)/g, " ") + .replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/^\s{0,3}(#{1,6}|>|[-*+]|\d+\.)\s+/gm, "") + .replace(/[`*_~]/g, "") + .replace(/\s+/g, " ") + .trim(); +} diff --git a/apps/web/src/lib/weeks.test.ts b/apps/web/src/lib/weeks.test.ts new file mode 100644 index 0000000..0f6a3cf --- /dev/null +++ b/apps/web/src/lib/weeks.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { weekKey, weeksEnding } from "./weeks"; + +describe("ISO weeks", () => { + it("numbers weeks like the server", () => { + expect(weekKey(new Date("2026-09-13T00:00:00Z"))).toBe("2026-W37"); + expect(weekKey(new Date("2027-01-01T00:00:00Z"))).toBe("2026-W53"); + expect(weekKey(new Date("2025-12-29T00:00:00Z"))).toBe("2026-W01"); + }); + + it("fills a continuous range across a year boundary", () => { + const weeks = weeksEnding("2027-W02", 4).map((w) => w.key); + expect(weeks).toEqual(["2026-W52", "2026-W53", "2027-W01", "2027-W02"]); + }); +}); diff --git a/apps/web/src/lib/weeks.ts b/apps/web/src/lib/weeks.ts new file mode 100644 index 0000000..a84f01c --- /dev/null +++ b/apps/web/src/lib/weeks.ts @@ -0,0 +1,33 @@ +/** ISO week keys as the server writes them: `2026-W07`. */ + +function mondayOf(key: string): Date { + const match = /^(\d{4})-W(\d{2})$/.exec(key); + if (!match) { + throw new Error(`not an ISO week: ${key}`); + } + const year = Number(match[1]); + const week = Number(match[2]); + // Week 1 is the week with January 4th in it. + const jan4 = new Date(Date.UTC(year, 0, 4)); + const monday = new Date(jan4); + monday.setUTCDate(jan4.getUTCDate() - ((jan4.getUTCDay() + 6) % 7) + (week - 1) * 7); + return monday; +} + +export function weekKey(date: Date): string { + const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())); + d.setUTCDate(d.getUTCDate() + 3 - ((d.getUTCDay() + 6) % 7)); + const year = d.getUTCFullYear(); + const week = 1 + Math.round((d.getTime() - mondayOf(`${year}-W01`).getTime()) / 604_800_000); + return `${year}-W${String(Math.min(week, 53)).padStart(2, "0")}`; +} + +/** The `count` weeks ending with `last`, oldest first, each with its Monday. */ +export function weeksEnding(last: string, count: number): { key: string; monday: Date }[] { + const end = mondayOf(last); + return Array.from({ length: count }, (_, i) => { + const monday = new Date(end); + monday.setUTCDate(end.getUTCDate() - (count - 1 - i) * 7); + return { key: weekKey(monday), monday }; + }); +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx new file mode 100644 index 0000000..a7e9683 --- /dev/null +++ b/apps/web/src/main.tsx @@ -0,0 +1,22 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { RouterProvider } from "@tanstack/react-router"; +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { queryClient } from "./api/queries"; +import { applyTheme } from "./components/ThemeToggle"; +import { router } from "./router"; +import "./styles.css"; + +applyTheme(); + +const container = document.getElementById("root"); +if (!container) { + throw new Error("index.html has no #root element"); +} +createRoot(container).render( + <StrictMode> + <QueryClientProvider client={queryClient}> + <RouterProvider router={router} /> + </QueryClientProvider> + </StrictMode>, +); diff --git a/apps/web/src/pages/HomePage.tsx b/apps/web/src/pages/HomePage.tsx new file mode 100644 index 0000000..b981359 --- /dev/null +++ b/apps/web/src/pages/HomePage.tsx @@ -0,0 +1,85 @@ +import { useQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import { ArrowRight, TriangleAlert } from "lucide-react"; +import { q } from "../api/queries"; +import { NoteList } from "../components/NoteList"; +import { TopicRows } from "../components/TopicRows"; +import { Empty, ErrorBox, Loading, PageHeader, Section } from "../components/ui"; + +const RECENT = 8; +const TOP = 6; + +export function HomePage() { + const stats = useQuery(q.stats()); + const recent = useQuery(q.notes({ limit: RECENT })); + const problems = useQuery(q.problems()); + + if (stats.isPending || recent.isPending) { + return <Loading />; + } + if (stats.isError) { + return <ErrorBox error={stats.error} />; + } + if (recent.isError) { + return <ErrorBox error={recent.error} />; + } + const s = stats.data; + const repeated = s.top_topics.filter((t) => t.ask_count >= 2); + const undigested = repeated.filter((t) => !t.annotated); + const problemCount = + (problems.data?.conflicts.length ?? 0) + (problems.data?.invalid_files.length ?? 0); + + if (s.notes === 0) { + return ( + <> + <PageHeader title="Distill" /> + <Empty> + 还没有 note。在 Codex 或 Claude Code 里聊完一个值得记住的问题后,说“distill 一下”。 + </Empty> + </> + ); + } + return ( + <div> + <PageHeader title="Distill"> + {s.notes} 条 note,{s.topics} 个 topic,其中 {s.repeated_topics} 个问过不止一次。 + </PageHeader> + {problemCount > 0 && ( + <Link + to="/problems" + className="mb-8 flex items-center gap-3 rounded-panel border border-error/40 bg-error-soft px-4 py-3 text-sm text-error" + > + <TriangleAlert className="size-4 shrink-0" aria-hidden />有 {problemCount}{" "} + 个文件需要处理:同步冲突或读不了的文件。 + <ArrowRight className="ml-auto size-4" aria-hidden /> + </Link> + )} + <Section title="问得最多" action={<More to="/topics" />}> + {repeated.length > 0 ? ( + <TopicRows topics={repeated.slice(0, TOP)} /> + ) : ( + <Empty>还没有问过第二次的问题。</Empty> + )} + </Section> + {undigested.length > 0 && ( + <Section title="重问过、还没写 annotation"> + <p className="mb-3 text-sm text-ink-faint"> + 问了不止一次却没留下自己的理解,这些最可能还没真正弄懂。 + </p> + <TopicRows topics={undigested.slice(0, TOP)} /> + </Section> + )} + <Section title="最近的 note" action={<More to="/notes" />}> + <NoteList notes={recent.data} empty="还没有 note。" /> + </Section> + </div> + ); +} + +function More({ to }: { to: string }) { + return ( + <Link to={to} className="text-sm text-brand-ink hover:underline"> + 全部 → + </Link> + ); +} diff --git a/apps/web/src/pages/NotePage.tsx b/apps/web/src/pages/NotePage.tsx new file mode 100644 index 0000000..a967c2d --- /dev/null +++ b/apps/web/src/pages/NotePage.tsx @@ -0,0 +1,107 @@ +import { useQuery } from "@tanstack/react-query"; +import { Link, useParams } from "@tanstack/react-router"; +import { ExternalLink, FileText } from "lucide-react"; +import type { NoteDetail } from "../api/generated/NoteDetail"; +import { q } from "../api/queries"; +import { Annotations } from "../components/Annotations"; +import { Markdown } from "../components/Markdown"; +import { AskCount, CopyButton, ErrorBox, Loading, Section, TagChip } from "../components/ui"; +import { agentLabel, formatDateTime, projectName, stripTitle } from "../lib/format"; + +export function NotePage() { + const { noteId } = useParams({ from: "/notes/$noteId" }); + const note = useQuery(q.note(noteId)); + if (note.isPending) { + return <Loading />; + } + if (note.isError) { + return <ErrorBox error={note.error} />; + } + const d = note.data; + return ( + <article> + <Link + to="/topics/$topicId" + params={{ topicId: d.note.topic_id }} + className="group mb-4 inline-flex max-w-full items-center gap-2 text-sm text-ink-faint hover:text-brand-ink" + > + <span className="shrink-0">Topic</span> + <span className="truncate underline-offset-4 group-hover:underline">{d.topic_label}</span> + <AskCount count={d.ask_count} /> + </Link> + <h1 className="text-[1.875rem] leading-tight sm:text-4xl">{d.note.title}</h1> + <Meta detail={d} /> + {d.note.tags.length > 0 && ( + <div className="mt-4 flex flex-wrap gap-1.5"> + {d.note.tags.map((tag) => ( + <TagChip key={tag} tag={tag} /> + ))} + </div> + )} + <div className="mt-8"> + <Markdown source={stripTitle(d.markdown)} /> + </div> + <Section title="Annotation"> + <Annotations noteId={d.note.id} annotations={d.note.annotations} /> + </Section> + <Section title="原会话"> + <Reopen detail={d} /> + </Section> + </article> + ); +} + +function Meta({ detail }: { detail: NoteDetail }) { + const { note } = detail; + return ( + <dl className="mt-3 flex flex-wrap gap-x-5 gap-y-1 text-sm text-ink-faint"> + <div> + <dt className="sr-only">时间</dt> + <dd> + <time dateTime={note.created}>{formatDateTime(note.created)}</time> + </dd> + </div> + <div> + <dt className="sr-only">来源</dt> + <dd>{agentLabel[note.source.agent]}</dd> + </div> + <div className="min-w-0"> + <dt className="sr-only">项目</dt> + <dd className="truncate" title={note.source.git_repo ?? note.source.cwd}> + {projectName(note.source)} + </dd> + </div> + </dl> + ); +} + +function Reopen({ detail }: { detail: NoteDetail }) { + const { reopen, note } = detail; + return ( + <div className="space-y-3"> + <a + href={reopen.link} + className="inline-flex items-center gap-2 text-brand-ink underline underline-offset-4" + > + <ExternalLink className="size-4" aria-hidden />在 {agentLabel[note.source.agent]}{" "} + 里打开这次会话 + </a> + <p className="text-sm text-ink-faint"> + 链接没反应时(没装桌面 App,或 App 改了链接格式),在终端运行: + </p> + <div className="flex items-center gap-1 rounded-panel border border-line bg-card pl-3"> + <code className="min-w-0 flex-1 overflow-x-auto py-2 font-mono text-sm whitespace-nowrap text-ink"> + {reopen.command} + </code> + <CopyButton text={reopen.command} label="复制命令" /> + </div> + <div className="flex items-center gap-2 text-sm text-ink-faint"> + <FileText className="size-4 shrink-0" aria-hidden /> + <span className="min-w-0 truncate font-mono text-xs" title={detail.file}> + {detail.file} + </span> + <CopyButton text={detail.file} label="复制文件路径" /> + </div> + </div> + ); +} diff --git a/apps/web/src/pages/NotesPage.tsx b/apps/web/src/pages/NotesPage.tsx new file mode 100644 index 0000000..489ce2e --- /dev/null +++ b/apps/web/src/pages/NotesPage.tsx @@ -0,0 +1,48 @@ +import { useQuery } from "@tanstack/react-query"; +import { useNavigate, useSearch } from "@tanstack/react-router"; +import { Search } from "lucide-react"; +import { useDeferredValue, useState } from "react"; +import { q } from "../api/queries"; +import { NoteList } from "../components/NoteList"; +import { ErrorBox, Loading, PageHeader } from "../components/ui"; + +const LIMIT = 500; + +export function NotesPage() { + const search = useSearch({ from: "/notes" }); + const navigate = useNavigate({ from: "/notes" }); + const [input, setInput] = useState(search.q ?? ""); + const text = useDeferredValue(input.trim()); + const notes = useQuery(q.notes({ q: text, limit: LIMIT })); + + return ( + <div> + <PageHeader title="Notes" /> + <label className="mb-6 flex items-center gap-2 rounded-panel border border-line-strong bg-panel px-3 focus-within:border-brand"> + <Search className="size-4 text-ink-faint" aria-hidden /> + <input + value={input} + onChange={(event) => { + setInput(event.target.value); + void navigate({ search: { q: event.target.value || undefined }, replace: true }); + }} + placeholder="关键词:标题、问题、正文" + aria-label="搜索 note" + className="h-10 w-full bg-transparent text-ink outline-none placeholder:text-ink-faint" + /> + </label> + {notes.isPending ? ( + <Loading /> + ) : notes.isError ? ( + <ErrorBox error={notes.error} /> + ) : ( + <> + <p className="mb-3 text-sm text-ink-faint"> + {text ? `${notes.data.length} 条匹配` : `最近的 ${notes.data.length} 条`} + </p> + <NoteList notes={notes.data} empty={text ? "没有匹配的 note。" : "还没有 note。"} /> + </> + )} + </div> + ); +} diff --git a/apps/web/src/pages/ProblemsPage.tsx b/apps/web/src/pages/ProblemsPage.tsx new file mode 100644 index 0000000..d0eb56f --- /dev/null +++ b/apps/web/src/pages/ProblemsPage.tsx @@ -0,0 +1,83 @@ +import { useQuery } from "@tanstack/react-query"; +import { api } from "../api/client"; +import type { Conflict } from "../api/generated/Conflict"; +import { q, useEdit } from "../api/queries"; +import { Button, Empty, ErrorBox, Loading, PageHeader, Panel, Section } from "../components/ui"; + +export function ProblemsPage() { + const problems = useQuery(q.problems()); + if (problems.isPending) { + return <Loading />; + } + if (problems.isError) { + return <ErrorBox error={problems.error} />; + } + const { conflicts, invalid_files } = problems.data; + return ( + <div> + <PageHeader title="需要处理" /> + <Section title="同步冲突"> + <p className="mb-4 text-sm text-ink-faint"> + 两台设备同时改了同一个文件,同步工具留下了多份。选一份保留;其余的移到本机数据目录的 + <code className="mx-1 font-mono text-xs">discarded/</code>,不会删除。 + </p> + {conflicts.length === 0 ? ( + <Empty>没有同步冲突。</Empty> + ) : ( + <div className="space-y-4"> + {conflicts.map((c) => ( + <ConflictCard key={`${c.kind}:${c.id}`} conflict={c} /> + ))} + </div> + )} + </Section> + <Section title="读不了的文件"> + <p className="mb-4 text-sm text-ink-faint"> + 这些文件格式不对,没有进入索引。用编辑器修好后会自动重新读取。 + </p> + {invalid_files.length === 0 ? ( + <Empty>所有文件都读得了。</Empty> + ) : ( + <ul className="space-y-2"> + {invalid_files.map((f) => ( + <li key={f.path} className="rounded-panel border border-line bg-panel px-3 py-2"> + <div className="font-mono text-sm text-ink">{f.path}</div> + <div className="mt-1 text-sm text-error">{f.error}</div> + </li> + ))} + </ul> + )} + </Section> + </div> + ); +} + +function ConflictCard({ conflict }: { conflict: Conflict }) { + const resolve = useEdit((keep: string) => + api.resolveConflict({ kind: conflict.kind, id: conflict.id, keep }), + ); + return ( + <Panel className="p-4"> + <div className="text-sm text-ink-faint"> + {conflict.kind} <span className="font-mono text-xs">{conflict.id}</span> + </div> + <ul className="mt-3 space-y-2"> + {conflict.paths.map((path) => ( + <li key={path} className="flex items-center gap-3"> + <span className="min-w-0 flex-1 truncate font-mono text-sm text-ink" title={path}> + {path} + </span> + <Button disabled={resolve.isPending} onClick={() => resolve.mutate(path)}> + 保留这份 + </Button> + </li> + ))} + </ul> + {resolve.error && ( + <div className="mt-3"> + <ErrorBox error={resolve.error} /> + </div> + )} + </Panel> + ); +} diff --git a/apps/web/src/pages/StatsPage.tsx b/apps/web/src/pages/StatsPage.tsx new file mode 100644 index 0000000..183de97 --- /dev/null +++ b/apps/web/src/pages/StatsPage.tsx @@ -0,0 +1,73 @@ +import { useQuery } from "@tanstack/react-query"; +import { q } from "../api/queries"; +import { BarList, Columns } from "../components/charts"; +import { TopicRows } from "../components/TopicRows"; +import { Empty, ErrorBox, Loading, PageHeader, Section, Stat } from "../components/ui"; +import { weekKey, weeksEnding } from "../lib/weeks"; + +const WEEKS = 16; +const TOP_BARS = 12; + +export function StatsPage() { + const stats = useQuery(q.stats()); + if (stats.isPending) { + return <Loading />; + } + if (stats.isError) { + return <ErrorBox error={stats.error} />; + } + const s = stats.data; + const perWeek = new Map(s.by_week.map((w) => [w.week, w.notes])); + const columns = weeksEnding(weekKey(new Date()), WEEKS).map(({ key, monday }) => ({ + key, + label: `${monday.getUTCMonth() + 1}/${monday.getUTCDate()}`, + value: perWeek.get(key) ?? 0, + })); + const undigested = s.top_topics.filter((t) => t.ask_count >= 2 && !t.annotated); + + return ( + <div> + <PageHeader title="统计" /> + <div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> + <Stat label="Note" value={s.notes} /> + <Stat label="Topic" value={s.topics} /> + <Stat label="重问过的 topic" value={s.repeated_topics} hint="问过 2 次及以上" /> + <Stat label="Annotation" value={s.annotations} /> + </div> + <Section title={`每周 distill 的 note(最近 ${WEEKS} 周)`}> + <Columns columns={columns} unit="条" /> + </Section> + <Section title="问得最多的 topic"> + <TopicRows topics={s.top_topics.filter((t) => t.ask_count >= 2)} /> + </Section> + <Section title="重问过、还没写 annotation 的 topic"> + {undigested.length > 0 ? ( + <TopicRows topics={undigested} /> + ) : ( + <Empty>重问过的 topic 都写了 annotation。</Empty> + )} + </Section> + <div className="grid gap-x-10 md:grid-cols-2"> + <Section title="按 tag"> + <BarList + bars={s.tags.slice(0, TOP_BARS).map((t) => ({ + key: t.tag, + label: `#${t.tag}`, + value: t.notes, + }))} + link={(b) => ({ to: "/tags/$tag", params: { tag: b.key } })} + /> + </Section> + <Section title="按项目"> + <BarList + bars={s.by_project.slice(0, TOP_BARS).map((p) => ({ + key: p.project, + label: p.project.split("/").slice(-2).join("/"), + value: p.notes, + }))} + /> + </Section> + </div> + </div> + ); +} diff --git a/apps/web/src/pages/TagsPage.tsx b/apps/web/src/pages/TagsPage.tsx new file mode 100644 index 0000000..97c197b --- /dev/null +++ b/apps/web/src/pages/TagsPage.tsx @@ -0,0 +1,48 @@ +import { useQuery } from "@tanstack/react-query"; +import { Link, useParams } from "@tanstack/react-router"; +import { q } from "../api/queries"; +import { BarList } from "../components/charts"; +import { NoteList } from "../components/NoteList"; +import { Empty, ErrorBox, Loading, PageHeader } from "../components/ui"; + +export function TagsPage() { + const tags = useQuery(q.tags()); + if (tags.isPending) { + return <Loading />; + } + if (tags.isError) { + return <ErrorBox error={tags.error} />; + } + return ( + <div> + <PageHeader title="Tags">{tags.data.length} 个 tag,按 note 数排列。</PageHeader> + {tags.data.length === 0 ? ( + <Empty>还没有 tag。</Empty> + ) : ( + <BarList + bars={tags.data.map((t) => ({ key: t.tag, label: `#${t.tag}`, value: t.notes }))} + link={(b) => ({ to: "/tags/$tag", params: { tag: b.key } })} + /> + )} + </div> + ); +} + +export function TagPage() { + const { tag } = useParams({ from: "/tags/$tag" }); + const notes = useQuery(q.notes({ tag, limit: 500 })); + return ( + <div> + <PageHeader eyebrow={<Link to="/tags">Tags</Link>} title={`#${tag}`}> + {notes.data && `${notes.data.length} 条 note`} + </PageHeader> + {notes.isPending ? ( + <Loading /> + ) : notes.isError ? ( + <ErrorBox error={notes.error} /> + ) : ( + <NoteList notes={notes.data} empty="这个 tag 下没有 note。" /> + )} + </div> + ); +} diff --git a/apps/web/src/pages/TopicPage.tsx b/apps/web/src/pages/TopicPage.tsx new file mode 100644 index 0000000..578b6eb --- /dev/null +++ b/apps/web/src/pages/TopicPage.tsx @@ -0,0 +1,205 @@ +import { useQuery } from "@tanstack/react-query"; +import { Link, useNavigate, useParams } from "@tanstack/react-router"; +import { GitMerge, Pencil } from "lucide-react"; +import { useEffect, useState } from "react"; +import { api } from "../api/client"; +import type { TopicDetail } from "../api/generated/TopicDetail"; +import { q, useEdit } from "../api/queries"; +import { TopicPicker } from "../components/TopicPicker"; +import { + AskCount, + Button, + ErrorBox, + Loading, + PageHeader, + Section, + TagChip, +} from "../components/ui"; +import { excerpt, formatDate } from "../lib/format"; + +export function TopicPage() { + const { topicId } = useParams({ from: "/topics/$topicId" }); + const topic = useQuery(q.topic(topicId)); + const navigate = useNavigate(); + + // A merged-away topic resolves to the one it was merged into; show that one's URL. + const resolved = topic.data?.topic_id; + useEffect(() => { + if (resolved && resolved !== topicId) { + void navigate({ to: "/topics/$topicId", params: { topicId: resolved }, replace: true }); + } + }, [resolved, topicId, navigate]); + + if (topic.isPending) { + return <Loading />; + } + if (topic.isError) { + return <ErrorBox error={topic.error} />; + } + const t = topic.data; + return ( + <div> + <Label topic={t} /> + <Section title="时间线"> + <Timeline topic={t} /> + </Section> + {t.merged_from.length > 0 && ( + <Section title="已合并进来的 topic"> + <ul className="space-y-1 text-sm"> + {t.merged_from.map((m) => ( + <li key={m.topic_id} className="text-ink-muted"> + {m.label} <span className="font-mono text-xs text-ink-faint">{m.topic_id}</span> + </li> + ))} + </ul> + </Section> + )} + <Section title="合并"> + <Merge topic={t} /> + </Section> + </div> + ); +} + +function Label({ topic }: { topic: TopicDetail }) { + const [editing, setEditing] = useState(false); + const [label, setLabel] = useState(topic.label); + const rename = useEdit((next: string) => api.renameTopic(topic.topic_id, { label: next })); + const first = topic.notes[0]; + const last = topic.notes.at(-1); + + return ( + <PageHeader + eyebrow={ + <span className="flex items-center gap-2"> + Topic <AskCount count={topic.ask_count} /> + </span> + } + title={ + editing ? ( + <form + className="flex flex-col gap-2 sm:flex-row sm:items-center" + onSubmit={(event) => { + event.preventDefault(); + rename.mutate(label, { onSuccess: () => setEditing(false) }); + }} + > + <input + value={label} + onChange={(event) => setLabel(event.target.value)} + aria-label="Topic 名称" + className="min-w-0 flex-1 rounded-panel border border-line-strong bg-panel px-2 py-1 font-display text-2xl text-ink focus:border-brand focus:outline-none" + /> + <div className="flex gap-2"> + <Button type="submit" variant="primary" disabled={rename.isPending || !label.trim()}> + 保存 + </Button> + <Button + onClick={() => { + setLabel(topic.label); + setEditing(false); + }} + > + 取消 + </Button> + </div> + </form> + ) : ( + <span className="group"> + {topic.label} + <button + type="button" + aria-label="改名" + title="改名" + onClick={() => setEditing(true)} + className="ml-2 inline-flex size-8 translate-y-[-2px] items-center justify-center rounded-[1px] align-middle text-ink-faint transition-colors duration-150 hover:bg-panel-hover hover:text-ink" + > + <Pencil className="size-4" /> + </button> + </span> + ) + } + > + {rename.error && <ErrorBox error={rename.error} />} + {first && last && ( + <p className="text-sm text-ink-faint"> + {topic.sessions} 个会话 · 第一次 {formatDate(first.created)} · 最近{" "} + {formatDate(last.created)} + </p> + )} + </PageHeader> + ); +} + +function Timeline({ topic }: { topic: TopicDetail }) { + return ( + <ol className="relative ml-1.5 border-l border-line-strong"> + {topic.notes.map((note, i) => ( + <li key={note.id} className="relative pb-7 pl-6 last:pb-0"> + <span + className="absolute top-2 -left-[5px] size-[9px] border border-brand bg-panel" + aria-hidden + /> + <div className="text-xs text-ink-faint"> + 第 {i + 1} 次 · <time dateTime={note.created}>{formatDate(note.created)}</time> + {note.annotations.length > 0 && ` · ${note.annotations.length} 条 annotation`} + </div> + <Link + to="/notes/$noteId" + params={{ noteId: note.id }} + className="mt-1 block font-display text-lg leading-snug text-ink hover:text-brand-ink" + > + {note.title} + </Link> + <p className="mt-1 line-clamp-3 text-sm text-ink-muted">{excerpt(note.conclusion)}</p> + {note.tags.length > 0 && ( + <div className="mt-2 flex flex-wrap gap-1.5"> + {note.tags.map((tag) => ( + <TagChip key={tag} tag={tag} /> + ))} + </div> + )} + </li> + ))} + </ol> + ); +} + +function Merge({ topic }: { topic: TopicDetail }) { + const [picking, setPicking] = useState(false); + const navigate = useNavigate(); + const merge = useEdit((into: string) => api.mergeTopic(topic.topic_id, { into })); + + return ( + <div className="space-y-3"> + <p className="text-sm text-ink-faint"> + 同一个问题被存成了两个 topic 时,把这个 topic 合并进另一个:提问次数合在一起,note + 文件不变,只在这个 topic 的文件里记下合并去向。 + </p> + {picking ? ( + <TopicPicker + exclude={topic.topic_id} + onCancel={() => setPicking(false)} + onPick={(target) => { + if ( + window.confirm( + `把「${topic.label}」合并进「${target.label}」?合并后这个 topic 不再单独出现。`, + ) + ) { + merge.mutate(target.topic_id, { + onSuccess: (merged) => + navigate({ to: "/topics/$topicId", params: { topicId: merged.topic_id } }), + }); + } + }} + /> + ) : ( + <Button onClick={() => setPicking(true)}> + <GitMerge className="size-4" aria-hidden /> + 合并进另一个 topic… + </Button> + )} + {merge.error && <ErrorBox error={merge.error} />} + </div> + ); +} diff --git a/apps/web/src/pages/TopicsPage.tsx b/apps/web/src/pages/TopicsPage.tsx new file mode 100644 index 0000000..e8264fd --- /dev/null +++ b/apps/web/src/pages/TopicsPage.tsx @@ -0,0 +1,65 @@ +import { useQuery } from "@tanstack/react-query"; +import { useNavigate, useSearch } from "@tanstack/react-router"; +import type { TopicCount } from "../api/generated/TopicCount"; +import { q } from "../api/queries"; +import { TopicRows } from "../components/TopicRows"; +import { cx, ErrorBox, Loading, PageHeader } from "../components/ui"; + +export type TopicView = "asked" | "recent" | "undigested"; + +const VIEWS: { value: TopicView; label: string }[] = [ + { value: "asked", label: "问得最多" }, + { value: "recent", label: "最近问过" }, + { value: "undigested", label: "重问过、没有 annotation" }, +]; + +function arrange(topics: TopicCount[], view: TopicView): TopicCount[] { + switch (view) { + case "asked": + return topics; + case "recent": + return [...topics].sort((a, b) => b.last_asked.localeCompare(a.last_asked)); + case "undigested": + return topics.filter((t) => t.ask_count >= 2 && !t.annotated); + } +} + +export function TopicsPage() { + const { view = "asked" } = useSearch({ from: "/topics" }); + const navigate = useNavigate({ from: "/topics" }); + const topics = useQuery(q.topics()); + + return ( + <div> + <PageHeader title="Topics"> + 一个 topic 是一个问题。同一个问题问了几次,就有几条 note。 + </PageHeader> + <div className="mb-5 flex flex-wrap gap-1" role="tablist" aria-label="排序"> + {VIEWS.map((v) => ( + <button + key={v.value} + type="button" + role="tab" + aria-selected={view === v.value} + onClick={() => navigate({ search: { view: v.value }, replace: true })} + className={cx( + "rounded-[1px] px-2.5 py-1 text-sm transition-colors duration-150", + view === v.value + ? "bg-brand-strong text-on-brand" + : "text-ink-muted hover:bg-panel-hover hover:text-ink", + )} + > + {v.label} + </button> + ))} + </div> + {topics.isPending ? ( + <Loading /> + ) : topics.isError ? ( + <ErrorBox error={topics.error} /> + ) : ( + <TopicRows topics={arrange(topics.data, view)} /> + )} + </div> + ); +} diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx new file mode 100644 index 0000000..d9053ca --- /dev/null +++ b/apps/web/src/router.tsx @@ -0,0 +1,52 @@ +import { createRootRoute, createRoute, createRouter } from "@tanstack/react-router"; +import { Layout } from "./components/Layout"; +import { Empty } from "./components/ui"; +import { HomePage } from "./pages/HomePage"; +import { NotePage } from "./pages/NotePage"; +import { NotesPage } from "./pages/NotesPage"; +import { ProblemsPage } from "./pages/ProblemsPage"; +import { StatsPage } from "./pages/StatsPage"; +import { TagPage, TagsPage } from "./pages/TagsPage"; +import { TopicPage } from "./pages/TopicPage"; +import { TopicsPage, type TopicView } from "./pages/TopicsPage"; + +const root = createRootRoute({ + component: Layout, + notFoundComponent: () => <Empty>这个页面不存在。</Empty>, +}); + +const TOPIC_VIEWS: TopicView[] = ["asked", "recent", "undigested"]; + +const routeTree = root.addChildren([ + createRoute({ getParentRoute: () => root, path: "/", component: HomePage }), + createRoute({ + getParentRoute: () => root, + path: "/notes", + component: NotesPage, + validateSearch: (search: Record<string, unknown>): { q?: string } => ({ + q: typeof search.q === "string" && search.q ? search.q : undefined, + }), + }), + createRoute({ getParentRoute: () => root, path: "/notes/$noteId", component: NotePage }), + createRoute({ + getParentRoute: () => root, + path: "/topics", + component: TopicsPage, + validateSearch: (search: Record<string, unknown>): { view?: TopicView } => ({ + view: TOPIC_VIEWS.find((v) => v === search.view), + }), + }), + createRoute({ getParentRoute: () => root, path: "/topics/$topicId", component: TopicPage }), + createRoute({ getParentRoute: () => root, path: "/tags", component: TagsPage }), + createRoute({ getParentRoute: () => root, path: "/tags/$tag", component: TagPage }), + createRoute({ getParentRoute: () => root, path: "/stats", component: StatsPage }), + createRoute({ getParentRoute: () => root, path: "/problems", component: ProblemsPage }), +]); + +export const router = createRouter({ routeTree, scrollRestoration: true }); + +declare module "@tanstack/react-router" { + interface Register { + router: typeof router; + } +} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css new file mode 100644 index 0000000..8480565 --- /dev/null +++ b/apps/web/src/styles.css @@ -0,0 +1,183 @@ +@import "tailwindcss"; +@import "@fontsource/ibm-plex-serif/latin-400.css"; +@import "@fontsource/ibm-plex-serif/latin-500.css"; +@import "@fontsource/ia-writer-quattro/latin-400.css"; +@import "@fontsource/ia-writer-quattro/latin-700.css"; +@import "@fontsource/lilex/latin-400.css"; + +/* Semantic roles mapped from the Delta reference (.agents/skills/distill-ui-design). + Light values are the defaults; [data-theme="dark"] overrides the same variables. Plain + @theme (not inline) keeps utilities pointing at the variables so the override applies. */ +@theme { + --color-canvas: #f3eee6; + --color-panel: #faf8f5; + --color-panel-hover: #f5f0ea; + --color-card: #f1eae0; + --color-ink: #2f3339; + --color-ink-muted: #545a64; + --color-ink-faint: #7a808a; + --color-line: #e0d9cf; + --color-line-strong: #afb3b9; + --color-brand: #d5442c; + --color-brand-strong: #be3c26; + --color-brand-ink: #9d301e; + --color-brand-soft: rgb(213 68 44 / 0.1); + --color-on-brand: #faf8f5; + --color-focus: #eca597; + --color-error: #b63132; + --color-error-soft: rgb(182 49 50 / 0.1); + --color-success: #227849; + --color-warning: #ab6f00; + --color-warning-soft: rgb(171 111 0 / 0.12); + + --font-display: "IBM Plex Serif", ui-serif, Georgia, "Songti SC", "Noto Serif CJK SC", serif; + --font-body: + "iA Writer Quattro", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Noto Sans CJK SC", + ui-sans-serif, system-ui, sans-serif; + --font-mono: "Lilex", ui-monospace, "SF Mono", Menlo, monospace; + + --radius-panel: 2px; + --tracking-heading: -0.025em; +} + +@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *)); + +[data-theme="dark"] { + --color-canvas: #121010; + --color-panel: #1b1918; + --color-panel-hover: #23201e; + --color-card: #221e1c; + --color-ink: #ece9e6; + --color-ink-muted: #b0aaa3; + --color-ink-faint: #837c75; + --color-line: #332f2c; + --color-line-strong: #5a5450; + --color-brand: #e0553b; + --color-brand-strong: #be3c26; + --color-brand-ink: #f08a72; + --color-brand-soft: rgb(224 85 59 / 0.14); + --color-on-brand: #faf8f5; + --color-focus: #a8452f; + --color-error: #e0676a; + --color-error-soft: rgb(224 103 106 / 0.14); + --color-success: #52b27d; + --color-warning: #d9a13a; + --color-warning-soft: rgb(217 161 58 / 0.14); +} + +@layer base { + html { + background: var(--color-canvas); + color: var(--color-ink-muted); + font-family: var(--font-body); + font-size: 15px; + line-height: 1.6; + -webkit-font-smoothing: antialiased; + } + + h1, + h2, + h3 { + font-family: var(--font-display); + color: var(--color-ink); + letter-spacing: var(--tracking-heading); + font-weight: 400; + } + + :focus-visible { + outline: 2px solid var(--color-focus); + outline-offset: 2px; + } + + ::selection { + background: var(--color-brand-soft); + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + /* biome-ignore lint/complexity/noImportantStyles: must beat every utility's duration. */ + transition-duration: 0ms !important; + } +} + +/* Rendered note Markdown. */ +.prose-note { + color: var(--color-ink-muted); + font-size: 1rem; + line-height: 1.75; +} +.prose-note > * + * { + margin-top: 1.25rem; +} +.prose-note h2 { + font-size: 1.375rem; + line-height: 1.3; + margin-top: 2.5rem; + padding-bottom: 0.4rem; + border-bottom: 1px solid var(--color-line); +} +.prose-note h3 { + font-size: 1.125rem; + margin-top: 1.75rem; +} +.prose-note ul { + list-style: disc; + padding-left: 1.25rem; +} +.prose-note ol { + list-style: decimal; + padding-left: 1.5rem; +} +.prose-note li + li { + margin-top: 0.35rem; +} +.prose-note li::marker { + color: var(--color-brand); +} +.prose-note a { + color: var(--color-brand-ink); + text-decoration: underline; + text-underline-offset: 3px; +} +.prose-note strong { + color: var(--color-ink); +} +.prose-note code { + font-family: var(--font-mono); + font-size: 0.875em; + border: 1px solid var(--color-line); + padding: 0.05rem 0.35rem; +} +.prose-note pre { + font-family: var(--font-mono); + font-size: 0.85rem; + line-height: 1.6; + background: var(--color-card); + border: 1px solid var(--color-line); + border-radius: var(--radius-panel); + padding: 0.9rem 1rem; + overflow-x: auto; +} +.prose-note pre code { + border: 0; + padding: 0; + font-size: inherit; +} +.prose-note blockquote { + border-left: 2px solid var(--color-brand); + padding-left: 1rem; + color: var(--color-ink-faint); +} +.prose-note table { + border-collapse: collapse; + font-size: 0.9rem; +} +.prose-note th, +.prose-note td { + border: 1px solid var(--color-line); + padding: 0.35rem 0.6rem; + text-align: left; +} diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json new file mode 100644 index 0000000..9e2dada --- /dev/null +++ b/apps/web/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "react-jsx", + "strict": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "skipLibCheck": true, + "types": ["vite/client", "node"] + }, + "include": ["src", "vite.config.ts"] +} diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts new file mode 100644 index 0000000..20ef64e --- /dev/null +++ b/apps/web/vite.config.ts @@ -0,0 +1,24 @@ +import babel from "@rolldown/plugin-babel"; +import tailwindcss from "@tailwindcss/vite"; +import react, { reactCompilerPreset } from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +// `pnpm dev` proxies the API to a running `distill ui`. The server only accepts requests +// from its own origin, so the proxy rewrites Origin to match. See docs/web-ui.md. +const target = `http://distill.localhost:${process.env.DISTILL_UI_PORT ?? "4777"}`; + +export default defineConfig({ + plugins: [react(), babel({ presets: [reactCompilerPreset()] }), tailwindcss()], + server: { + proxy: { + "/api": { + target, + changeOrigin: true, + headers: { origin: target }, + }, + "/auth": { target, changeOrigin: true }, + }, + }, + // Served from loopback by `distill ui`, so one ~600 kB bundle loads instantly. + build: { outDir: "dist", emptyOutDir: true, chunkSizeWarningLimit: 800 }, +}); diff --git a/docs/development.md b/docs/development.md index eaf4f14..50c3504 100644 --- a/docs/development.md +++ b/docs/development.md @@ -9,8 +9,15 @@ mise install mise run check ``` -`mise run check` runs `cargo fmt --check`, `cargo clippy -D warnings` and `cargo test` for the -whole workspace. A change is not done until it passes. +`mise run check` installs the web dependencies, runs `cargo fmt --check`, +`cargo clippy -D warnings` and `cargo test` for the whole workspace, then Biome, `tsc`, Vitest +and a production build for `apps/web`. A change is not done until it passes. + +| Task | Does | +|---|---| +| `mise run web:build` | Builds `apps/web/dist` | +| `mise run install` | Builds the web UI, then `cargo install --path crates/distill-cli --locked` | +| `mise run fmt` | Formats Rust and web code | ## Layout @@ -19,6 +26,7 @@ whole workspace. A change is not done until it passes. | `crates/distill-core` | Library: vault files, SQLite index, operations shared by every front end | | `crates/distill-cli` | The `distill` binary | | `crates/distill-core/migrations` | Index schema as numbered SQL files | +| `apps/web` | The web UI ([README](../apps/web/README.md), [web-ui.md](web-ui.md)) | | `plugins/distill` | Codex / Claude Code plugin ([plugin.md](plugin.md)) | | `packaging/` | Marketplace manifests, one directory per agent | | `scripts/` | Build scripts run by `mise` tasks | @@ -27,7 +35,16 @@ whole workspace. A change is not done until it passes. | `spec/` | One directory per decision ([spec/AGENTS.md](../spec/AGENTS.md)) | | `.agents/skills/` | Skills for agents working in this repo | -The web UI (`apps/web`) arrives in a later phase. +A release build (`cargo build --release`, `cargo install`) embeds `apps/web/dist` as it is at +compile time; build the web UI first. A debug build reads `apps/web/dist` from disk at run time, +so `pnpm -C apps/web build` is enough to see UI changes. + +## API types + +Request and response types are Rust. `cargo test` writes their TypeScript versions to +`apps/web/src/api/generated/` (the export directory is set in `.cargo/config.toml`). Commit the +regenerated files with the Rust change; CI fails when they differ from what `cargo test` +produces. To try the plugin from this checkout, run `mise run build:plugins` and add `dist/plugins/claude-code` or `dist/plugins/codex` as a local marketplace, then install diff --git a/docs/index.md b/docs/index.md index 558cc4a..b69374b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -20,5 +20,6 @@ How Distill works today. Why it works this way is in | `crates/distill-core` | [README](../crates/distill-core/README.md): vault, index, save/recall/search/stats | | `crates/distill-cli` | [README](../crates/distill-cli/README.md): the `distill` command | | `plugins/distill` | [README](../plugins/distill/README.md): the agent plugin | +| `apps/web` | [README](../apps/web/README.md): the web UI | Touching more than one package: read both READMEs and [vault-format.md](vault-format.md). diff --git a/mise.toml b/mise.toml index e68094e..0b68ab9 100644 --- a/mise.toml +++ b/mise.toml @@ -1,19 +1,41 @@ [tools] rust = { version = "1.95", components = "rustfmt,clippy" } +node = "22" +pnpm = "10.30.2" jq = "1.8" [tasks.fmt] -description = "Format all Rust code" -run = "cargo fmt --all" +description = "Format all Rust and web code" +run = ["cargo fmt --all", "pnpm -C apps/web format"] + +[tasks."web:install"] +description = "Install the web UI's dependencies" +run = "pnpm install --frozen-lockfile" + +[tasks."web:build"] +description = "Build the web UI into apps/web/dist, which `distill` embeds" +depends = ["web:install"] +run = "pnpm -C apps/web build" [tasks.check] description = "Everything a change must pass before it is done" +depends = ["web:install"] +# cargo test regenerates the ts-rs API types before the web checks read them. run = [ "cargo fmt --all --check", "cargo clippy --workspace --all-targets -- -D warnings", "cargo test --workspace", + "pnpm -C apps/web lint", + "pnpm -C apps/web typecheck", + "pnpm -C apps/web test", + "pnpm -C apps/web build", ] +[tasks.install] +description = "Build the web UI and install the distill binary with it embedded" +depends = ["web:build"] +run = "cargo install --path crates/distill-cli --locked" + [tasks."build:plugins"] description = "Build per-agent plugin directories under dist/plugins (args: claude-code, codex)" run = "scripts/build-plugins.sh" diff --git a/package.json b/package.json new file mode 100644 index 0000000..d767d90 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "name": "distill", + "private": true, + "packageManager": "pnpm@10.30.2" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..8dfd183 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,3271 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: {} + + apps/web: + dependencies: + '@fontsource/ia-writer-quattro': + specifier: 5.3.0 + version: 5.3.0 + '@fontsource/ibm-plex-serif': + specifier: 5.3.0 + version: 5.3.0 + '@fontsource/lilex': + specifier: 5.3.0 + version: 5.3.0 + '@tanstack/react-query': + specifier: 5.104.0 + version: 5.104.0(react@19.3.0) + '@tanstack/react-router': + specifier: 1.170.39 + version: 1.170.39(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + cmdk: + specifier: 1.1.1 + version: 1.1.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + lucide-react: + specifier: 1.48.0 + version: 1.48.0(react@19.3.0) + react: + specifier: 19.3.0 + version: 19.3.0 + react-dom: + specifier: 19.3.0 + version: 19.3.0(react@19.3.0) + react-markdown: + specifier: 10.1.0 + version: 10.1.0(@types/react@19.3.0)(react@19.3.0) + remark-gfm: + specifier: 4.0.1 + version: 4.0.1 + virtua: + specifier: 0.52.7 + version: 0.52.7(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + devDependencies: + '@babel/core': + specifier: ^8.0.6 + version: 8.0.6 + '@biomejs/biome': + specifier: 2.5.14 + version: 2.5.14 + '@rolldown/plugin-babel': + specifier: ^0.2.4 + version: 0.2.4(@babel/core@8.0.6)(rolldown@1.2.11)(vite@8.3.1(@types/node@22.20.4)(jiti@2.7.0)) + '@tailwindcss/vite': + specifier: 4.3.3 + version: 4.3.3(vite@8.3.1(@types/node@22.20.4)(jiti@2.7.0)) + '@types/babel__core': + specifier: ^7.20.5 + version: 7.20.5 + '@types/node': + specifier: ^22.20.4 + version: 22.20.4 + '@types/react': + specifier: ^19.3.0 + version: 19.3.0 + '@types/react-dom': + specifier: ^19.3.0 + version: 19.3.0(@types/react@19.3.0) + '@vitejs/plugin-react': + specifier: 6.1.1 + version: 6.1.1(@rolldown/plugin-babel@0.2.4(@babel/core@8.0.6)(rolldown@1.2.11)(vite@8.3.1(@types/node@22.20.4)(jiti@2.7.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.1(@types/node@22.20.4)(jiti@2.7.0)) + babel-plugin-react-compiler: + specifier: 1.0.0 + version: 1.0.0 + tailwindcss: + specifier: 4.3.3 + version: 4.3.3 + typescript: + specifier: 7.0.2 + version: 7.0.2 + vite: + specifier: 8.3.1 + version: 8.3.1(@types/node@22.20.4)(jiti@2.7.0) + vitest: + specifier: 5.0.2 + version: 5.0.2(@types/node@22.20.4)(vite@8.3.1(@types/node@22.20.4)(jiti@2.7.0)) + +packages: + + '@babel/code-frame@8.0.6': + resolution: {integrity: sha512-uMHhvmDmwFiLuiozaoRYTZHqMFuu3fem/X9JY78lohAuLoh8LSGGC8JqUzo2cVuBsBxkjMZNk56CE7ock8Xxkw==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/compat-data@8.0.5': + resolution: {integrity: sha512-YLsYoQMvL8l8WrGpN3Zj7O1wK5LEBN+cQtux7BcuHyxIXve724XG+zuJ1n3U1cUweRtTzQOA4IHbuQw3N34SZw==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/core@8.0.6': + resolution: {integrity: sha512-5zwYt1V4ji3mXKKRIAkohSJ29WepxNwxQi+7y7P5AcVPh1dcxnZBU2cfxHnPwS+u9+lZnXPsv30TQaF2xg+Tfg==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/generator@8.0.6': + resolution: {integrity: sha512-/pcanjdRsonCexCuxgGD8fKnBLLzk2i9uM4kmcmGyyev2R5YDs+rafMYZ89RvaGY1mW8T7C5au0TyYaEeHBGng==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/helper-compilation-targets@8.0.6': + resolution: {integrity: sha512-HTrtZ4+iA0+kzdbCgvdSk1U4pEl3BvpGnlUbualCGNbrVhhQvhgBzurTUmqpWyvyHbWYWaIfdx2mOlL9LJjsew==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/helper-globals@8.0.6': + resolution: {integrity: sha512-OdnA/N52LhzFpeUuVz5YGuSWjc7TPHiFmVYZbPlSgExocO2GwbxVpSni9MVs5wwW+wZ4kPuwBEBN9pIPDYz1mA==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@8.0.6': + resolution: {integrity: sha512-+6HYEBnsDRZHJtUTAEfMQd0zMDqdFHS7C+5vAsEZqBUr3vEMWOWlHJ0Jz1gS8jJIlCvLsuA6ivmeoqzH936YcA==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@8.0.6': + resolution: {integrity: sha512-HYx0YmuchRxRSpnjvcNffnPS8DCauKNmnumIuHG3jnQAVewRA7kfJGwF13ayEpSZs7s4R/oBH4eppuXcAQd5JQ==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/helper-validator-option@8.0.0': + resolution: {integrity: sha512-U4Dybxh4WESWHt5XhBeExi4DrY0/DNK1aHpQbsrQXCUbFHuMweT0TpLEWKvaraV2Y6fS+ZXunsZ8zIuZIgvF2Q==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/helpers@8.0.5': + resolution: {integrity: sha512-fQtPOXjYOYv85PIdwotp2TJGVYOycX0PQq+l844fFAxOULtBy8BVF35GyeueX0r4KvDthqPH5xAI1clQPk/2uA==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/parser@8.0.6': + resolution: {integrity: sha512-LpGDIYJAzc3Y3PT9x+FxBrGYu2PlWxLmyN7SwPTzaX0LSwEmFJBsRnPBqmaXF2r1v+Zhz6lNiEJ+7yKtM5+Ugg==} + engines: {node: ^22.18.0 || >=24.11.0} + hasBin: true + + '@babel/template@8.0.0': + resolution: {integrity: sha512-eAD0QW/AlbamBbw0FeGiwasbCVPq5ncW0HNVyLP3B9czqLyh4gvw+5JTSNt6le9+ziAU7mqDZsKTHf3jTb4chQ==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/traverse@8.0.6': + resolution: {integrity: sha512-ilYV+qLStGzSKLipJlETdvdtpK3rAuwfCmCoGKv15MU4EoeIuLkespSNoNLFGOPSciEHx9TLWtN5mv0fz5w5mQ==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@babel/types@8.0.6': + resolution: {integrity: sha512-c+xgSWboV2pdwXP67BUWDVRHi2BO5Q3KwxyFVs7taVTY+fsK47/L51ZXQoV9rozHJDNHvJ4v4WEsI/IlxV2l2A==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@biomejs/biome@2.5.14': + resolution: {integrity: sha512-0FabLIjd4M/dm8VFI86RMaLLdgepzbdfiAL2R8cr7V81OYYrP1w7Z73KfAwPK5h9SrEBXTzNG2y+mBwPo9xRnw==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.5.14': + resolution: {integrity: sha512-UnzaXO65L4tsZimFITFP2M121GyhDcWFrT3pL5ZJ5U4XcS/0L5VHYytVohdCf/gnDUFHgl2I9xnt5bV/J1kxHQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.5.14': + resolution: {integrity: sha512-kiy8qA16K93J7uvFfWi4LgjqDpnRKyePAna6A0Y4jxyUga35SYpIZ2cNWlhy0lsfgqRenCpfymnJWEZ6mgpRgA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.5.14': + resolution: {integrity: sha512-SJ9PrZkBnnH9dHJDnxk34vKs0GB2dbsioaft6/hPhWJ7AHpwYH83Isnhj0FSRpFkGgpVfJ1lds23ApB2czUDLQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-arm64@2.5.14': + resolution: {integrity: sha512-vO/9BaU1n30CiFNLx49gMTMtbCAAqlo/EqFoG0BU3deQInTbJrmXSmTQ9e3FoDZIKQnvSLDVNL4lx1ci7y1T1Q==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-linux-x64-musl@2.5.14': + resolution: {integrity: sha512-2kI5PrMgW5dcEZYrstLPmUmCwkUwZY39rP4BN93Vxbwcs2O57LDQOktUZZYPvvspN5i0mhGr3TJtF5sU26NUWg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-x64@2.5.14': + resolution: {integrity: sha512-VHZRa7CCQBUWKxNwUvHJeuW03WFRgN5NWO//SDGOitc9QIeNyfA42V9zlKm+x82xFSG9Bzi5fi+hbhm9anO8yA==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-win32-arm64@2.5.14': + resolution: {integrity: sha512-pHgAFffmZtaYoxavEsWEYNvcA7NwOIjLyqw2HXyLbt16xYryX0L4fMV2u0G9ag6LNYPIoqWaWqzUcnc6rLGGXg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.5.14': + resolution: {integrity: sha512-oJWmBhoHsnhUKIke+0gXDX0mltJrWHA1UyHsrTlXwX0TL64ilVZAo+TYZm97baecV0esBdpzy3k96q+09JaZUQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@fontsource/ia-writer-quattro@5.3.0': + resolution: {integrity: sha512-J249e+/1kBy9YzJ1JKRPDm0V3ELGwqSlxf3WlB7qNiExLA+kXGN+VwOhILNH21ZcezFVilvA9+ev2r/kZSyy/g==} + + '@fontsource/ibm-plex-serif@5.3.0': + resolution: {integrity: sha512-zwPfHB7EJbS5B8qnitPigJYzNdnt6PUeaoOA1gAAt//1pjpTVZN5sOU14NIAZ+C1xypL6GWsUG2VMYJW9bhJqQ==} + + '@fontsource/lilex@5.3.0': + resolution: {integrity: sha512-iSmg+PfNAlXotUVkXvxRhHaHC2OQeFRjWurb3AnJWPTmw+M6JZKIvj0MfwYjkdL5mCw5AiBSE2UxPAhN5Idh/g==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/gen-mapping@0.4.0-beta.0': + resolution: {integrity: sha512-JdGNkbE4GlNPYQhM0L95fBQr7ctLZJ276QXQLTad4t1oSdnnCI3fDq9DW3BqYAWv8Wc3+HS+4Gsii1oPMCfz1w==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@oxc-project/types@0.151.0': + resolution: {integrity: sha512-J1yXrIlNDZVzE3ada310xeAw7nH8yCAyLPuUIsjKatFPmfn5bS1oW+cM+QsGOtVWd5nhSpbwZWx/rue+r5Z+PA==} + + '@radix-ui/primitive@1.1.7': + resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==} + + '@radix-ui/react-compose-refs@1.1.5': + resolution: {integrity: sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context@1.2.2': + resolution: {integrity: sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.23': + resolution: {integrity: sha512-Ksw4WeROkO4rC9k/onilX/Ao2Cr1ku1unMNH+XSCcP4jSXYu7HDsg9n4ojMjVb22XpYjAQ9qfrFlVbru1vXDUA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.19': + resolution: {integrity: sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.6': + resolution: {integrity: sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.16': + resolution: {integrity: sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.4': + resolution: {integrity: sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-portal@1.1.17': + resolution: {integrity: sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.10': + resolution: {integrity: sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.10': + resolution: {integrity: sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.3.3': + resolution: {integrity: sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.4': + resolution: {integrity: sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.6': + resolution: {integrity: sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.5': + resolution: {integrity: sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.4': + resolution: {integrity: sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@rolldown/binding-android-arm-eabi@1.2.11': + resolution: {integrity: sha512-A5kXfGKvKWWZE0TtPrfsvT+q4Y5d1QG8gGUzpYjGydM+fARM9MuX90PrXYXe0XbsDVgyxxNzHo6giCj90bsFNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.11': + resolution: {integrity: sha512-z6cTycz+iJ4PVkuL4HHW4DfTfoeU/2nqYYuSOrTmH7yHK5Y0LCOnA03V4ZNxavyVaU1oOqUgIg2klN/s+USGOA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.11': + resolution: {integrity: sha512-jShvqNtP6vDC6/A5JOAzbVV+DkgHqhl/ScVCJEbt+TUY6QYz7YnXcrg3sLtFBniro0f/Ld50ZwCWA6f7KYD1nQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.11': + resolution: {integrity: sha512-f2i2xiNWq1Z1l2++q2fuhZRdLAT3aqxD6vRNm1RAxpUoBcdqNB3C0s1Bt+K+PbEx2F5F4gQp6hqKkphCY/xF9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.11': + resolution: {integrity: sha512-4Ir5FSOKIAMr4r0kExpt1s3bMgzJU3rA45AYOHtQpls0oNeqcYBKrWMlckrYH4KCfGLfkfn1tN1dmZPMVsdXow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.11': + resolution: {integrity: sha512-/gnRDM+39BROzAN/k1OZjDPnDMcZxB/0EUxKjONO5yVkNEvlsoMDrxGNKgZi/ttFriS2gwlDNzB65pvNbFOXIQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.11': + resolution: {integrity: sha512-PFaK8HwvAHbaKbBcDNQihjMKYvFnA5hiENx/l5tphTDz1E0WFp32l0A7aq7lyUwGsRw/xSrNIy/gIK4thrSCrw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.11': + resolution: {integrity: sha512-AskzJUIKRLPxkruR1wLKewGbOw+EYfU/9lOrBFj4AFrEA8hPpKFnODWNu2WLaNs0QNkEb9QIJufmVZZIL/bJlg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.11': + resolution: {integrity: sha512-qlUGAheh2yh8afH7QBgx0PrRHN85hKnNd78x8MeMhXivuevgd8vgf6/CstOzmNKY/lLTHvNTrPy98cLnAugzJw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.11': + resolution: {integrity: sha512-secpEad+0vCbSfn8upFySkDskv+bGPk3THSDS9Y89yc4rb4kzqHp8Dmyd9BkQW4SnhNXBZCl/6CrO//hZahNJQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.11': + resolution: {integrity: sha512-mOVBT3dPpkWm8XBWPmU4bf+U6dYDLeMo/9ojUmis4N0L5uu10qra5vOyngZ7/PSdoE4G9KvRt4bloRxNjLas7A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.11': + resolution: {integrity: sha512-Is78i9A8Ui4SqcxUwFJ9uMmjDn58IbVTjFWYdQestFEgeuEmHMLGNriXnVJKkwG2YiZjw8cP0zCTyDMdDGtOOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.11': + resolution: {integrity: sha512-dUCXneZ87INUMyQ0D+C0HrEBNUPNXHaPmU5GTjyKTJEiussw9Kaj5Ln8UztPe4epV/ffvgNBEadksdYhmW6xJA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.11': + resolution: {integrity: sha512-jByxb6qfd+bH1xUd0qnfFnb17i9sWBPY2tOavJ0l3tdr3OTu+Kvtm8cd/JV5nFt657b1VqGltxg9olOEfofXWw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.11': + resolution: {integrity: sha512-/PzKqzAJ03i19oy2ItPvyvaVjOjBCNnfaJs8yvUdGBKmiESgnrJSQ2awd81QzFbbnAmu7YO9ZnJrDCb9VSJPRA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/plugin-babel@0.2.4': + resolution: {integrity: sha512-ygmm2j/mN+aCvstHo0x3IOu4uc6LqXT9SJ3q4kpfG/+Yhfz0B+E47FITcC0FljZADwUFARP6E2kiE+Lc26D8wg==} + engines: {node: '>=22.12.0 || ^24.0.0'} + peerDependencies: + '@babel/core': ^7.29.0 || ^8.0.0-rc.1 + '@babel/plugin-transform-runtime': ^7.29.0 || ^8.0.0-rc.1 + '@babel/runtime': ^7.27.0 || ^8.0.0-rc.1 + rolldown: ^1.0.0-rc.5 + vite: ^8.0.0 + peerDependenciesMeta: + '@babel/plugin-transform-runtime': + optional: true + '@babel/runtime': + optional: true + vite: + optional: true + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@tailwindcss/node@4.3.3': + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} + + '@tailwindcss/oxide-android-arm64@4.3.3': + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.3': + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.3': + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.3': + resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@tanstack/history@1.162.4': + resolution: {integrity: sha512-utTS5L2OkeYUzXGohL1Z8sefu1GLNOJcxe8Hd6iIdc/Xo1K1nDB2JEp4iSFhvYh33xKC9V91TxrS8qfrpoKobQ==} + engines: {node: '>=20.19'} + + '@tanstack/query-core@5.104.0': + resolution: {integrity: sha512-JrC2r/JQlXt7khBSdUpsgxNvybzOg+aITa+ARRMlP2AFo93Y8vIqz077rp+e61mJetSZ0T6AJbwW1JHer1vrPQ==} + + '@tanstack/react-query@5.104.0': + resolution: {integrity: sha512-e1TZmDCQnWIfiDVryIHeA6Idj+Lfx1hORLOhXS/l5wcgvtVD4yYqbTDl378sGQKIi2cSgb0TEMC9cKK8GGoJEw==} + peerDependencies: + react: ^18 || ^19 + + '@tanstack/react-router@1.170.39': + resolution: {integrity: sha512-wLZi/hXW/kTeLlMIvI2VB8Oj38GLz8YkHmTM14lLScAzx79UZEwBHWMj6JZw/imuyuhrcmbe8KTFsFBHzJUftg==} + engines: {node: '>=20.19'} + peerDependencies: + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + + '@tanstack/react-store@0.11.1': + resolution: {integrity: sha512-HaIGKI3YLmjBYIvy5DFDY23oNaYZIsTZfngey07Uh5iLVJgM3bIGCnZeOFOqzjFld9JHWcaHJnasD/bKoGKwJQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/router-core@1.171.32': + resolution: {integrity: sha512-X86Jqk3vB2KJcUfS6oLi7B7yxbaa59QEhZRPPP1fRx3k+Jw/Fu1UYVBcRtdwK/OccQnrlQdVoKvNO3QXmyEBOw==} + engines: {node: '>=20.19'} + + '@tanstack/store@0.11.1': + resolution: {integrity: sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/gensync@1.0.5': + resolution: {integrity: sha512-MbsRCT7mTikHwKZ0X+LVUTLRrZZRLipTuXEO9qOYO+zmjMVk81axyClMROf6uoPD9MRVu46bx8zoR0Ad9q3NAg==} + + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + + '@types/jsesc@2.5.1': + resolution: {integrity: sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/node@22.20.4': + resolution: {integrity: sha512-zJRE40jpHtKqE/C4fgHrAKQLJuSpzEnP9ff9Y7YtoR3Wd2pwqzlekDeEuUQXjRd+QCYnVnNwuJYmhdk9XV8gvA==} + + '@types/react-dom@19.3.0': + resolution: {integrity: sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==} + peerDependencies: + '@types/react': ^19.3.0 + + '@types/react@19.3.0': + resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} + + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@ungap/structured-clone@1.4.0': + resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} + + '@vitejs/plugin-react@6.1.1': + resolution: {integrity: sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + oxc-transform-react: ^0.145.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + oxc-transform-react: + optional: true + + '@vitest/mocker@5.0.2': + resolution: {integrity: sha512-Z5FS00Q1SJHkB35xATsmWGdQ5WA1/0MV3CDjqyv7GavHv1OfOj145MNfHOlHk7QLes21dKFDHr8EO2zvL+9WGA==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/spy@5.0.2': + resolution: {integrity: sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + babel-plugin-react-compiler@1.0.0: + resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==} + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + + baseline-browser-mapping@2.11.26: + resolution: {integrity: sha512-GLQdD3y6UF8iVuMJl5fHgE4jdn/ua7n+toKfLgNlg3BqQtOZjpy68T8Tup8/wGWZCDlm7KMg7tPb4MPn7oN0TQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + browserslist@4.29.1: + resolution: {integrity: sha512-AUdjuRyCNGUYtqpqfTmWyM4fXay8yIQhmLnvYe/THMGfT9B/34X7xQd3ifKxwyNPPpowVBjLb+64BN9Rn1mizw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + caniuse-lite@1.0.30001812: + resolution: {integrity: sha512-qN+QNNBr93TCmFrmte0bBCjSDMuRvt78VlHT99qIGPszm4QsqCX8lnyWUFkHi8B7ZBAPq8SH+UqyXNy/odMdng==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + + cmdk@1.1.1: + resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-es@3.1.1: + resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + electron-to-chromium@1.5.439: + resolution: {integrity: sha512-qu6QIPXhsb+CRcAiTMNjR4A1y/7tCYKkKjr5CZXRVih6qkDf79peZ2BpEU3qoDBNCRrcy3Mra3X9nG5oruuA7Q==} + + empathic@2.1.0: + resolution: {integrity: sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw==} + engines: {node: '>=14'} + + enhanced-resolve@5.25.1: + resolution: {integrity: sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==} + engines: {node: '>=10.13.0'} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + flru@1.0.2: + resolution: {integrity: sha512-kWyh8ADvHBFz6ua5xYOPnUroZTT/bwWfrCeL0Wj1dzG4/YOmOcfJ99W8dOVyyynJN35rZ9aCOtHChqQovV7yog==} + engines: {node: '>=6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + html-url-attributes@3.0.1: + resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + isbot@5.2.2: + resolution: {integrity: sha512-iQcBXcd+Rv/pkubRyGh2utW2j1oPG5hZY6TUhVPpqK4G+o3IbxpJNx04hgksjc/N7GK5pEorUxDeg31cFgEk/w==} + engines: {node: '>=18'} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + lucide-react@1.48.0: + resolution: {integrity: sha512-R0CIKY/fXiC6y9xRBADgsK+VW2p/pcTJOMhLZf1T+uG+vJUvyUR02nGOgmIIC7MPkiNK4Ox8QDnbqF8rJYWPZQ==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magic-string@1.4.2: + resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==} + + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + micromark-core-commonmark@2.0.4: + resolution: {integrity: sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.2: + resolution: {integrity: sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.1.0: + resolution: {integrity: sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-edit-map@1.0.0: + resolution: {integrity: sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.3: + resolution: {integrity: sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==} + + micromark@4.0.3: + resolution: {integrity: sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.19: + resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-releases@2.0.57: + resolution: {integrity: sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==} + engines: {node: '>=18'} + + obug@2.2.1: + resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} + engines: {node: '>=12.20.0'} + + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + + react-dom@19.3.0: + resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==} + peerDependencies: + react: ^19.3.0 + + react-markdown@10.1.0: + resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} + peerDependencies: + '@types/react': '>=18' + react: '>=18' + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react@19.3.0: + resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} + engines: {node: '>=0.10.0'} + + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + + rolldown@1.2.11: + resolution: {integrity: sha512-qpSwIyz0jHQq5qXBTNxFmE6664rJ7O+4TvPFOiOaBSrz8IOHc1koKKSqTM2H6u1UG1+TveuC6vaDHKXFOvb1Kw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + scheduler@0.28.0: + resolution: {integrity: sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==} + + seroval-plugins@1.6.7: + resolution: {integrity: sha512-4Nk35ttD3DTDJW4hgw5StsVAPeU6qnDFnULAouw6tQ7oLTV/ICXrWpsXo2EE52eSP2joUMazbVf52mFEcADqRw==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.6.7: + resolution: {integrity: sha512-AeDcLh0yO2SFm9W71essgnSzLV9DI8ZH0x0knXn2DMnUZj728mpLbxjlbB6IqKCmqh8JA3cEqRyGoNkt584JcQ==} + engines: {node: '>=10'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tinybench@6.2.0: + resolution: {integrity: sha512-78U2TlB2CnVenajOFzf3BKSm0J6oz5L0NV7g32LCPccvYc0lbWvys4d3uUUCS2B1N8PAf2+aekR8i1KbC3HO7Q==} + engines: {node: '>=20.0.0'} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + update-browserslist-db@1.3.3: + resolution: {integrity: sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sync-external-store@1.7.0: + resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + verkit@0.3.2: + resolution: {integrity: sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==} + engines: {node: '>=18.12.0'} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + virtua@0.52.7: + resolution: {integrity: sha512-3xdhgKCbetu7Gez+DJfjmE+1zI7tsCN+07ZpUBhABDyqqT5r+fji6c0vmQCZlxI8o022d/LdIm2E/9T/LE9rPA==} + peerDependencies: + '@angular/common': '>=20.0.0' + '@angular/core': '>=20.0.0' + react: '>=16.14.0' + react-dom: '>=16.14.0' + solid-js: '>=1.0' + svelte: '>=5.0' + vue: '>=3.2' + peerDependenciesMeta: + '@angular/common': + optional: true + '@angular/core': + optional: true + react: + optional: true + react-dom: + optional: true + solid-js: + optional: true + svelte: + optional: true + vue: + optional: true + + vite@8.3.1: + resolution: {integrity: sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + 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 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@5.0.2: + resolution: {integrity: sha512-7MQrx9pDv5aHiUcovIb/70Ys3tgtkUVgCtledvKdCmEO+/1Dicq5ZqoSxOW034m03oqC+oHOKui2dM6qtMLoJg==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.2 + '@vitest/browser-preview': 5.0.2 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.2 + '@vitest/coverage-v8': 5.0.2 + '@vitest/ui': 5.0.2 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@3.2.2: + resolution: {integrity: sha512-NKUzAelcoCXhXL4dJzKIwXeR8iEVqsA0Lq6Vnd0UXvgaKbzVo4ZTHROF2Jidrv+SgxOQ03fMinnNhzZATxOD3A==} + engines: {node: '>=20.11'} + hasBin: true + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@babel/code-frame@8.0.6': + dependencies: + '@babel/helper-validator-identifier': 8.0.6 + js-tokens: 10.0.0 + + '@babel/compat-data@8.0.5': {} + + '@babel/core@8.0.6': + dependencies: + '@babel/code-frame': 8.0.6 + '@babel/generator': 8.0.6 + '@babel/helper-compilation-targets': 8.0.6 + '@babel/helpers': 8.0.5 + '@babel/parser': 8.0.6 + '@babel/template': 8.0.0 + '@babel/traverse': 8.0.6 + '@babel/types': 8.0.6 + '@types/gensync': 1.0.5 + convert-source-map: 2.0.0 + empathic: 2.1.0 + gensync: 1.0.0-beta.2 + import-meta-resolve: 4.2.0 + json5: 2.2.3 + obug: 2.2.1 + verkit: 0.3.2 + + '@babel/generator@8.0.6': + dependencies: + '@babel/parser': 8.0.6 + '@babel/types': 8.0.6 + '@jridgewell/gen-mapping': 0.4.0-beta.0 + '@jridgewell/trace-mapping': 0.3.31 + '@types/jsesc': 2.5.1 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@8.0.6': + dependencies: + '@babel/compat-data': 8.0.5 + '@babel/helper-validator-option': 8.0.0 + browserslist: 4.29.1 + flru: 1.0.2 + verkit: 0.3.2 + + '@babel/helper-globals@8.0.6': {} + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-string-parser@8.0.6': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-identifier@8.0.6': {} + + '@babel/helper-validator-option@8.0.0': {} + + '@babel/helpers@8.0.5': + dependencies: + '@babel/template': 8.0.0 + '@babel/types': 8.0.6 + + '@babel/parser@7.29.9': + dependencies: + '@babel/types': 7.29.8 + + '@babel/parser@8.0.6': + dependencies: + '@babel/types': 8.0.6 + + '@babel/template@8.0.0': + dependencies: + '@babel/code-frame': 8.0.6 + '@babel/parser': 8.0.6 + '@babel/types': 8.0.6 + + '@babel/traverse@8.0.6': + dependencies: + '@babel/code-frame': 8.0.6 + '@babel/generator': 8.0.6 + '@babel/helper-globals': 8.0.6 + '@babel/parser': 8.0.6 + '@babel/template': 8.0.0 + '@babel/types': 8.0.6 + obug: 2.2.1 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@babel/types@8.0.6': + dependencies: + '@babel/helper-string-parser': 8.0.6 + '@babel/helper-validator-identifier': 8.0.6 + + '@biomejs/biome@2.5.14': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.5.14 + '@biomejs/cli-darwin-x64': 2.5.14 + '@biomejs/cli-linux-arm64': 2.5.14 + '@biomejs/cli-linux-arm64-musl': 2.5.14 + '@biomejs/cli-linux-x64': 2.5.14 + '@biomejs/cli-linux-x64-musl': 2.5.14 + '@biomejs/cli-win32-arm64': 2.5.14 + '@biomejs/cli-win32-x64': 2.5.14 + + '@biomejs/cli-darwin-arm64@2.5.14': + optional: true + + '@biomejs/cli-darwin-x64@2.5.14': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.5.14': + optional: true + + '@biomejs/cli-linux-arm64@2.5.14': + optional: true + + '@biomejs/cli-linux-x64-musl@2.5.14': + optional: true + + '@biomejs/cli-linux-x64@2.5.14': + optional: true + + '@biomejs/cli-win32-arm64@2.5.14': + optional: true + + '@biomejs/cli-win32-x64@2.5.14': + optional: true + + '@fontsource/ia-writer-quattro@5.3.0': {} + + '@fontsource/ibm-plex-serif@5.3.0': {} + + '@fontsource/lilex@5.3.0': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/gen-mapping@0.4.0-beta.0': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@oxc-project/types@0.151.0': {} + + '@radix-ui/primitive@1.1.7': {} + + '@radix-ui/react-compose-refs@1.1.5(@types/react@19.3.0)(react@19.3.0)': + dependencies: + react: 19.3.0 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-context@1.2.2(@types/react@19.3.0)(react@19.3.0)': + dependencies: + react: 19.3.0 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-dialog@1.1.23(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-context': 1.2.2(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-slot': 1.3.3(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.0) + aria-hidden: 1.2.6 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + react-remove-scroll: 2.7.2(@types/react@19.3.0)(react@19.3.0) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-dismissable-layer@1.1.19(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-focus-guards@1.1.6(@types/react@19.3.0)(react@19.3.0)': + dependencies: + react: 19.3.0 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-focus-scope@1.1.16(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-id@1.1.4(@types/react@19.3.0)(react@19.3.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-portal@1.1.17(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-presence@1.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-primitive@2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@radix-ui/react-slot': 1.3.3(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + optionalDependencies: + '@types/react': 19.3.0 + '@types/react-dom': 19.3.0(@types/react@19.3.0) + + '@radix-ui/react-slot@1.3.3(@types/react@19.3.0)(react@19.3.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-callback-ref@1.1.4(@types/react@19.3.0)(react@19.3.0)': + dependencies: + react: 19.3.0 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-controllable-state@1.2.6(@types/react@19.3.0)(react@19.3.0)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-effect-event@0.0.5(@types/react@19.3.0)(react@19.3.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.3.0)(react@19.3.0) + react: 19.3.0 + optionalDependencies: + '@types/react': 19.3.0 + + '@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.3.0)(react@19.3.0)': + dependencies: + react: 19.3.0 + optionalDependencies: + '@types/react': 19.3.0 + + '@rolldown/binding-android-arm-eabi@1.2.11': + optional: true + + '@rolldown/binding-android-arm64@1.2.11': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.11': + optional: true + + '@rolldown/binding-darwin-x64@1.2.11': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.11': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.11': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.11': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.11': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.11': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.11': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.11': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.11': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.11': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.11': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.11': + optional: true + + '@rolldown/plugin-babel@0.2.4(@babel/core@8.0.6)(rolldown@1.2.11)(vite@8.3.1(@types/node@22.20.4)(jiti@2.7.0))': + dependencies: + '@babel/core': 8.0.6 + picomatch: 4.0.7 + rolldown: 1.2.11 + optionalDependencies: + vite: 8.3.1(@types/node@22.20.4)(jiti@2.7.0) + + '@rolldown/pluginutils@1.0.1': {} + + '@tailwindcss/node@4.3.3': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.25.1 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.3 + + '@tailwindcss/oxide-android-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide@4.3.3': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 + + '@tailwindcss/vite@4.3.3(vite@8.3.1(@types/node@22.20.4)(jiti@2.7.0))': + dependencies: + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + tailwindcss: 4.3.3 + vite: 8.3.1(@types/node@22.20.4)(jiti@2.7.0) + + '@tanstack/history@1.162.4': {} + + '@tanstack/query-core@5.104.0': {} + + '@tanstack/react-query@5.104.0(react@19.3.0)': + dependencies: + '@tanstack/query-core': 5.104.0 + react: 19.3.0 + + '@tanstack/react-router@1.170.39(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@tanstack/history': 1.162.4 + '@tanstack/react-store': 0.11.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@tanstack/router-core': 1.171.32 + isbot: 5.2.2 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + '@tanstack/react-store@0.11.1(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': + dependencies: + '@tanstack/store': 0.11.1 + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + use-sync-external-store: 1.7.0(react@19.3.0) + + '@tanstack/router-core@1.171.32': + dependencies: + '@tanstack/history': 1.162.4 + cookie-es: 3.1.1 + seroval: 1.6.7 + seroval-plugins: 1.6.7(seroval@1.6.7) + + '@tanstack/store@0.11.1': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.8 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/deep-eql@4.0.2': {} + + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.9 + + '@types/estree@1.0.9': {} + + '@types/gensync@1.0.5': {} + + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + + '@types/jsesc@2.5.1': {} + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/ms@2.1.0': {} + + '@types/node@22.20.4': + dependencies: + undici-types: 6.21.0 + + '@types/react-dom@19.3.0(@types/react@19.3.0)': + dependencies: + '@types/react': 19.3.0 + + '@types/react@19.3.0': + dependencies: + csstype: 3.2.3 + + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + '@ungap/structured-clone@1.4.0': {} + + '@vitejs/plugin-react@6.1.1(@rolldown/plugin-babel@0.2.4(@babel/core@8.0.6)(rolldown@1.2.11)(vite@8.3.1(@types/node@22.20.4)(jiti@2.7.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.3.1(@types/node@22.20.4)(jiti@2.7.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.3.1(@types/node@22.20.4)(jiti@2.7.0) + optionalDependencies: + '@rolldown/plugin-babel': 0.2.4(@babel/core@8.0.6)(rolldown@1.2.11)(vite@8.3.1(@types/node@22.20.4)(jiti@2.7.0)) + babel-plugin-react-compiler: 1.0.0 + + '@vitest/mocker@5.0.2(vite@8.3.1(@types/node@22.20.4)(jiti@2.7.0))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.2 + estree-walker: 3.0.3 + magic-string: 1.4.2 + optionalDependencies: + vite: 8.3.1(@types/node@22.20.4)(jiti@2.7.0) + + '@vitest/spy@5.0.2': {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + assertion-error@2.0.1: {} + + babel-plugin-react-compiler@1.0.0: + dependencies: + '@babel/types': 7.29.8 + + bail@2.0.2: {} + + baseline-browser-mapping@2.11.26: {} + + browserslist@4.29.1: + dependencies: + baseline-browser-mapping: 2.11.26 + caniuse-lite: 1.0.30001812 + electron-to-chromium: 1.5.439 + node-releases: 2.0.57 + update-browserslist-db: 1.3.3(browserslist@4.29.1) + + caniuse-lite@1.0.30001812: {} + + ccount@2.0.1: {} + + chai@6.2.2: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + + cmdk@1.1.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-dialog': 1.1.23(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@radix-ui/react-id': 1.1.4(@types/react@19.3.0)(react@19.3.0) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + + comma-separated-tokens@2.0.3: {} + + convert-source-map@2.0.0: {} + + cookie-es@3.1.1: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + detect-node-es@1.1.0: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + electron-to-chromium@1.5.439: {} + + empathic@2.1.0: {} + + enhanced-resolve@5.25.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + es-module-lexer@2.3.2: {} + + escalade@3.2.0: {} + + escape-string-regexp@5.0.0: {} + + estree-util-is-identifier-name@3.0.0: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + extend@3.0.2: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + flru@1.0.2: {} + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + get-nonce@1.0.1: {} + + graceful-fs@4.2.11: {} + + hast-util-to-jsx-runtime@2.3.6: + dependencies: + '@types/estree': 1.0.9 + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + html-url-attributes@3.0.1: {} + + import-meta-resolve@4.2.0: {} + + inline-style-parser@0.2.7: {} + + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + + is-decimal@2.0.1: {} + + is-hexadecimal@2.0.1: {} + + is-plain-obj@4.1.0: {} + + isbot@5.2.2: {} + + jiti@2.7.0: {} + + js-tokens@10.0.0: {} + + jsesc@3.1.0: {} + + json5@2.2.3: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + longest-streak@3.1.0: {} + + lucide-react@1.48.0(react@19.3.0): + dependencies: + react: 19.3.0 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + magic-string@1.4.2: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + markdown-table@3.0.4: {} + + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.3 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.4.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + micromark-core-commonmark@2.0.4: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.1.0 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-edit-map: 1.0.0 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.4 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-gfm-table@2.1.2: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.3 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.2 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-space@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.3 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-edit-map@1.0.0: + dependencies: + micromark-util-types: 2.0.3 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.3 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.3: {} + + micromark@4.0.3: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.4 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-edit-map: 1.0.0 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + transitivePeerDependencies: + - supports-color + + ms@2.1.3: {} + + nanoid@3.3.19: {} + + node-releases@2.0.57: {} + + obug@2.2.1: {} + + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + postcss@8.5.28: + dependencies: + nanoid: 3.3.19 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + property-information@7.2.0: {} + + react-dom@19.3.0(react@19.3.0): + dependencies: + react: 19.3.0 + scheduler: 0.28.0 + + react-markdown@10.1.0(@types/react@19.3.0)(react@19.3.0): + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@types/react': 19.3.0 + devlop: 1.1.0 + hast-util-to-jsx-runtime: 2.3.6 + html-url-attributes: 3.0.1 + mdast-util-to-hast: 13.2.1 + react: 19.3.0 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + react-remove-scroll-bar@2.3.8(@types/react@19.3.0)(react@19.3.0): + dependencies: + react: 19.3.0 + react-style-singleton: 2.2.3(@types/react@19.3.0)(react@19.3.0) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.3.0 + + react-remove-scroll@2.7.2(@types/react@19.3.0)(react@19.3.0): + dependencies: + react: 19.3.0 + react-remove-scroll-bar: 2.3.8(@types/react@19.3.0)(react@19.3.0) + react-style-singleton: 2.2.3(@types/react@19.3.0)(react@19.3.0) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.3.0)(react@19.3.0) + use-sidecar: 1.1.3(@types/react@19.3.0)(react@19.3.0) + optionalDependencies: + '@types/react': 19.3.0 + + react-style-singleton@2.2.3(@types/react@19.3.0)(react@19.3.0): + dependencies: + get-nonce: 1.0.1 + react: 19.3.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.3.0 + + react@19.3.0: {} + + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + micromark-util-types: 2.0.3 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + + rolldown@1.2.11: + dependencies: + '@oxc-project/types': 0.151.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.11 + '@rolldown/binding-android-arm64': 1.2.11 + '@rolldown/binding-darwin-arm64': 1.2.11 + '@rolldown/binding-darwin-x64': 1.2.11 + '@rolldown/binding-freebsd-x64': 1.2.11 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.11 + '@rolldown/binding-linux-arm64-gnu': 1.2.11 + '@rolldown/binding-linux-arm64-musl': 1.2.11 + '@rolldown/binding-linux-ppc64-gnu': 1.2.11 + '@rolldown/binding-linux-s390x-gnu': 1.2.11 + '@rolldown/binding-linux-x64-gnu': 1.2.11 + '@rolldown/binding-linux-x64-musl': 1.2.11 + '@rolldown/binding-openharmony-arm64': 1.2.11 + '@rolldown/binding-win32-arm64-msvc': 1.2.11 + '@rolldown/binding-win32-x64-msvc': 1.2.11 + + scheduler@0.28.0: {} + + seroval-plugins@1.6.7(seroval@1.6.7): + dependencies: + seroval: 1.6.7 + + seroval@1.6.7: {} + + source-map-js@1.2.1: {} + + space-separated-tokens@2.0.2: {} + + std-env@4.2.0: {} + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + + tailwindcss@4.3.3: {} + + tapable@2.3.3: {} + + tinybench@6.2.0: {} + + tinyexec@1.3.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + trim-lines@3.0.1: {} + + trough@2.2.0: {} + + tslib@2.8.1: {} + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + undici-types@6.21.0: {} + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + update-browserslist-db@1.3.3(browserslist@4.29.1): + dependencies: + browserslist: 4.29.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + use-callback-ref@1.3.3(@types/react@19.3.0)(react@19.3.0): + dependencies: + react: 19.3.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.3.0 + + use-sidecar@1.1.3(@types/react@19.3.0)(react@19.3.0): + dependencies: + detect-node-es: 1.1.0 + react: 19.3.0 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.3.0 + + use-sync-external-store@1.7.0(react@19.3.0): + dependencies: + react: 19.3.0 + + verkit@0.3.2: {} + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + virtua@0.52.7(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + optionalDependencies: + react: 19.3.0 + react-dom: 19.3.0(react@19.3.0) + + vite@8.3.1(@types/node@22.20.4)(jiti@2.7.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.11 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.4 + fsevents: 2.3.3 + jiti: 2.7.0 + + vitest@5.0.2(@types/node@22.20.4)(vite@8.3.1(@types/node@22.20.4)(jiti@2.7.0)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.2(vite@8.3.1(@types/node@22.20.4)(jiti@2.7.0)) + chai: 6.2.2 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.4.2 + obug: 2.2.1 + picomatch: 4.0.7 + std-env: 4.2.0 + tinybench: 6.2.0 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + vite: 8.3.1(@types/node@22.20.4)(jiti@2.7.0) + why-is-node-running: 3.2.2 + optionalDependencies: + '@types/node': 22.20.4 + transitivePeerDependencies: + - msw + + why-is-node-running@3.2.2: {} + + zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..06b6051 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - "apps/*" From 1869483e4515928db33fe8f6dd97f94aa1513a67 Mon Sep 17 00:00:00 2001 From: Yanlin <jackey.dou@gmail.com> Date: Sat, 26 Sep 2026 12:48:05 -0700 Subject: [PATCH 4/7] Add semantic recall and duplicate topic suggestions A local multilingual embedding model (paraphrase-multilingual-MiniLM-L12-v2, quantized, on ONNX Runtime via fastembed) is fetched once with `distill model pull`. Recall then fuses keyword and embedding rankings, so reworded and translated questions come back; without the model it stays keyword-only. Topic pairs that are probably one question are offered for merging in the web UI, and can be dismissed. Vectors are cached in the index and survive reindex. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- Cargo.lock | 1606 ++++++++++++++++- INSTALL.md | 3 + apps/web/CHANGELOG.md | 2 + apps/web/README.md | 4 +- apps/web/src/api/client.ts | 8 + apps/web/src/api/generated/Duplicates.ts | 8 + apps/web/src/api/generated/PairDismissal.ts | 3 + apps/web/src/api/generated/RecallResult.ts | 6 +- apps/web/src/api/generated/Session.ts | 6 +- apps/web/src/api/generated/SimilarTopic.ts | 4 + apps/web/src/api/generated/TopicPair.ts | 8 + apps/web/src/api/queries.ts | 3 + apps/web/src/components/Duplicates.tsx | 82 + apps/web/src/pages/HomePage.tsx | 15 +- apps/web/src/pages/TopicPage.tsx | 50 + apps/web/src/pages/TopicsPage.tsx | 14 +- apps/web/src/router.tsx | 2 +- crates/distill-cli/CHANGELOG.md | 5 + crates/distill-cli/README.md | 1 + crates/distill-cli/src/main.rs | 48 +- crates/distill-cli/src/mcp.rs | 9 +- crates/distill-cli/src/render.rs | 8 + crates/distill-cli/src/ui/api.rs | 41 +- crates/distill-core/CHANGELOG.md | 6 + crates/distill-core/Cargo.toml | 1 + crates/distill-core/README.md | 13 +- .../migrations/0002_embeddings.sql | 18 + crates/distill-core/src/embed.rs | 173 ++ crates/distill-core/src/error.rs | 9 + crates/distill-core/src/index/mod.rs | 10 +- crates/distill-core/src/index/query.rs | 25 +- crates/distill-core/src/index/recall.rs | 30 +- crates/distill-core/src/index/semantic.rs | 263 +++ crates/distill-core/src/lib.rs | 1 + crates/distill-core/src/ops.rs | 48 +- crates/distill-core/tests/semantic.rs | 142 ++ docs/index.md | 1 + docs/plugin.md | 2 +- docs/recall.md | 66 + docs/web-ui.md | 5 +- 40 files changed, 2668 insertions(+), 81 deletions(-) create mode 100644 apps/web/src/api/generated/Duplicates.ts create mode 100644 apps/web/src/api/generated/PairDismissal.ts create mode 100644 apps/web/src/api/generated/SimilarTopic.ts create mode 100644 apps/web/src/api/generated/TopicPair.ts create mode 100644 apps/web/src/components/Duplicates.tsx create mode 100644 crates/distill-core/migrations/0002_embeddings.sql create mode 100644 crates/distill-core/src/embed.rs create mode 100644 crates/distill-core/src/index/semantic.rs create mode 100644 crates/distill-core/tests/semantic.rs create mode 100644 docs/recall.md diff --git a/Cargo.lock b/Cargo.lock index a5bc02f..cd0756d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,26 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.5" @@ -11,6 +31,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android_system_properties" version = "0.1.6" @@ -161,6 +187,18 @@ dependencies = [ "tracing", ] +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64" version = "0.23.1" @@ -230,6 +268,15 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.4.7" @@ -246,6 +293,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "chacha20" version = "0.10.2" @@ -254,7 +307,7 @@ checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -315,6 +368,33 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "serde", + "static_assertions", +] + +[[package]] +name = "console" +version = "0.16.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys 0.61.2", +] + [[package]] name = "const-oid" version = "0.10.2" @@ -327,6 +407,35 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -342,6 +451,40 @@ dependencies = [ "libc", ] +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + [[package]] name = "crypto-common" version = "0.2.2" @@ -351,14 +494,44 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "daachorse" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5614204febbc33cc07a2806aa6440b904ac012b68eecc37f4493ea4a76455a3d" + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.24.1", + "darling_macro 0.24.1", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", ] [[package]] @@ -374,17 +547,37 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn 2.0.119", +] + [[package]] name = "darling_macro" version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ - "darling_core", + "darling_core 0.24.1", "quote", "syn 3.0.6", ] +[[package]] +name = "dary_heap" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" +dependencies = [ + "serde", +] + [[package]] name = "defmt" version = "1.1.1" @@ -416,6 +609,43 @@ dependencies = [ "thiserror", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.119", +] + [[package]] name = "difflib" version = "0.4.0" @@ -463,6 +693,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "distill-cli" version = "0.0.1" @@ -473,7 +714,7 @@ dependencies = [ "blake3", "clap", "distill-core", - "getrandom", + "getrandom 0.4.3", "listeners", "notify-debouncer-mini", "open", @@ -495,6 +736,7 @@ version = "0.0.1" dependencies = [ "blake3", "directories", + "fastembed", "jiff", "regex", "rusqlite", @@ -512,12 +754,33 @@ dependencies = [ "yaml_serde", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dyn-clone" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "equivalent" version = "1.0.2" @@ -534,6 +797,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "esaxx-rs" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -546,6 +815,22 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fastembed" +version = "7.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3850d724d559da51a4b88f68b0ba111a2627c32279bec6b3c256f073c71a3195" +dependencies = [ + "hf-hub", + "ndarray", + "ort", + "safetensors", + "serde", + "serde_json", + "thiserror", + "tokenizers", +] + [[package]] name = "fastrand" version = "2.5.0" @@ -558,6 +843,23 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ef25905e51abafe4dcea6c15fec58c57b601cdbd0ee53d22ea1d3016c587d39b" +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.2.0" @@ -670,6 +972,31 @@ dependencies = [ "slab", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -677,9 +1004,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", - "r-efi", - "rand_core", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -688,7 +1017,11 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", + "serde", + "serde_core", ] [[package]] @@ -715,6 +1048,32 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hf-hub" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef3982638978efa195ff11b305f51f1f22f4f0a6cabee7af79b383ebee6a213" +dependencies = [ + "dirs", + "http", + "indicatif", + "libc", + "log", + "rand 0.9.5", + "reqwest", + "serde", + "serde_json", + "thiserror", + "ureq", + "windows-sys 0.61.2", +] + +[[package]] +name = "hmac-sha256" +version = "1.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad320b3b96fb2a455a0726d16efe0a5afdbd34b71dea5bc53b05ea057714d4e" + [[package]] name = "http" version = "1.5.0" @@ -787,6 +1146,23 @@ dependencies = [ "pin-project-lite", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", ] [[package]] @@ -795,13 +1171,22 @@ version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ + "base64 0.23.1", "bytes", + "futures-channel", + "futures-util", "http", "http-body", + "httparse", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2", "tokio", "tower-service", + "tracing", ] [[package]] @@ -829,30 +1214,147 @@ dependencies = [ ] [[package]] -name = "ident_case" -version = "1.0.1" +name = "icu_collections" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] [[package]] -name = "indexmap" -version = "2.14.2" +name = "icu_locale_core" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ - "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", ] [[package]] -name = "inotify" -version = "0.11.5" +name = "icu_normalizer" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ - "bitflags 2.13.2", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "indicatif" +version = "0.18.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" +dependencies = [ + "console", + "portable-atomic", + "unicode-width", + "unit-prefix", + "web-time", +] + +[[package]] +name = "inotify" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" +dependencies = [ + "bitflags 2.13.2", "inotify-sys", "libc", ] @@ -866,6 +1368,12 @@ dependencies = [ "libc", ] +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + [[package]] name = "is-docker" version = "0.2.0" @@ -891,6 +1399,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1033,18 +1550,68 @@ dependencies = [ "windows", ] +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "log" version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "lzma-rust2" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e20f57f9918e5bd7bc58c22cdd70a6afc7375d4dd9683af5f2b34bd3d2bba619" + +[[package]] +name = "macro_rules_attribute" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3ae8f6d608c795738406608304d30a2dfbdc8e58e44f7ba43236da5208ded3c" +dependencies = [ + "macro_rules_attribute-proc_macro", + "pastey", +] + +[[package]] +name = "macro_rules_attribute-proc_macro" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc04a4c58212d57930a24bf47d3fa87485264a3a054e9c10e042eb373573ad3c" + [[package]] name = "matchit" version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "matrixmultiply" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" +dependencies = [ + "autocfg", + "rawpointer", +] + [[package]] name = "memchr" version = "2.8.3" @@ -1067,6 +1634,22 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.3" @@ -1079,6 +1662,53 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "monostate" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67" +dependencies = [ + "monostate-impl", + "serde", + "serde_core", +] + +[[package]] +name = "monostate-impl" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "notify" version = "8.2.0" @@ -1118,6 +1748,30 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1139,6 +1793,28 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "onig" +version = "6.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" +dependencies = [ + "bitflags 2.13.2", + "libc", + "once_cell", + "onig_sys", +] + +[[package]] +name = "onig_sys" +version = "69.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "open" version = "5.4.4" @@ -1155,6 +1831,36 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ort" +version = "2.0.0-rc.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4336a1e2b38848325241c72889086886004e589b7c74f335e60a8e8db5138a0b" +dependencies = [ + "ndarray", + "ort-sys", + "smallvec", + "tracing", + "ureq", +] + +[[package]] +name = "ort-sys" +version = "2.0.0-rc.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf211e3776eea6aec988552fa118dd746d70e1b1e5e244058d1c98015f3e5872" +dependencies = [ + "hmac-sha256", + "lzma-rust2", + "ureq", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pastey" version = "0.2.3" @@ -1194,6 +1900,30 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "predicates" version = "3.1.4" @@ -1230,6 +1960,62 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.47" @@ -1239,12 +2025,28 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.3" @@ -1252,8 +2054,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", - "getrandom", - "rand_core", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", ] [[package]] @@ -1262,6 +2083,52 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-cond" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" +dependencies = [ + "either", + "itertools", + "rayon", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_users" version = "0.5.3" @@ -1321,13 +2188,68 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rmcp" version = "3.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b6317cd8c13e3ec9033cf2aa5aa92cd743f0f4f8a93cddc42ea0ae6ce3b8898" dependencies = [ - "base64", + "base64 0.23.1", "chrono", "futures", "indexmap", @@ -1350,7 +2272,7 @@ version = "3.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2ee366004e5f87c103be91c6c09337811517ae8d2f919ece9da7c21444b85c5" dependencies = [ - "darling", + "darling 0.24.1", "proc-macro2", "quote", "serde_json", @@ -1429,6 +2351,12 @@ dependencies = [ "walkdir", ] +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustix" version = "1.1.5" @@ -1442,6 +2370,42 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.23" @@ -1454,6 +2418,19 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "safetensors" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79b079b829cb27a1c3c374341345ed2e8b2c0c839034522cee576c140bd7f846" +dependencies = [ + "hashbrown 0.16.1", + "libc", + "serde", + "serde_json", + "tempfile", +] + [[package]] name = "same-file" version = "1.0.6" @@ -1611,6 +2588,12 @@ dependencies = [ "libc", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "slab" version = "0.4.12" @@ -1633,6 +2616,29 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", +] + +[[package]] +name = "spm_precompiled" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326" +dependencies = [ + "base64 0.13.1", + "nom", + "serde", + "unicode-segmentation", +] + [[package]] name = "sqlite-wasm-rs" version = "0.5.5" @@ -1645,12 +2651,30 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.119" @@ -1678,6 +2702,20 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] [[package]] name = "tempfile" @@ -1686,7 +2724,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -1702,29 +2740,108 @@ dependencies = [ ] [[package]] -name = "termtree" -version = "0.5.1" +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] [[package]] -name = "thiserror" -version = "2.0.21" +name = "tinyvec" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" -dependencies = [ - "thiserror-impl", -] +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] -name = "thiserror-impl" -version = "2.0.21" +name = "tokenizers" +version = "0.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +checksum = "7afbf6e88718afcc138bad01d6ccc3051dbbc3b2ce9793d8b8a3aeb610969cfc" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", + "ahash", + "compact_str", + "daachorse", + "dary_heap", + "derive_builder", + "esaxx-rs", + "getrandom 0.3.4", + "itertools", + "log", + "macro_rules_attribute", + "monostate", + "onig", + "paste", + "rand 0.9.5", + "rayon", + "rayon-cond", + "regex", + "regex-syntax", + "serde", + "serde_json", + "spm_precompiled", + "thiserror", + "unicode-normalization-alignments", + "unicode-segmentation", + "unicode_categories", ] [[package]] @@ -1754,6 +2871,16 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.19" @@ -1835,6 +2962,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -1879,6 +3024,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "ts-rs" version = "12.0.1" @@ -1913,7 +3064,7 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "947dde63b6d514cc5e044edad4e0ca7261afd1099d16c83d942cb2b2f348689c" dependencies = [ - "rand", + "rand 0.10.3", "web-time", ] @@ -1929,17 +3080,64 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "unicode-normalization-alignments" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de" +dependencies = [ + "smallvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "ureq" version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" dependencies = [ - "base64", + "base64 0.23.1", + "cookie_store", + "flate2", "log", "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "socks", "ureq-proto", "utf8-zero", + "webpki-roots", ] [[package]] @@ -1948,18 +3146,36 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" dependencies = [ - "base64", + "base64 0.23.1", "http", "httparse", "log", ] +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + [[package]] name = "utf8-zero" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1972,7 +3188,7 @@ version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ - "getrandom", + "getrandom 0.4.3", "js-sys", "wasm-bindgen", ] @@ -1983,6 +3199,12 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wait-timeout" version = "0.2.1" @@ -2002,12 +3224,30 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.128" @@ -2021,6 +3261,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.128" @@ -2053,6 +3303,29 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web-time" version = "1.1.0" @@ -2063,6 +3336,31 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -2072,6 +3370,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows" version = "0.62.2" @@ -2173,13 +3477,22 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets", + "windows-targets 0.53.5", ] [[package]] @@ -2191,6 +3504,22 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + [[package]] name = "windows-targets" version = "0.53.5" @@ -2198,14 +3527,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ "windows-link", - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -2217,48 +3546,96 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_aarch64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + [[package]] name = "windows_i686_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_i686_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "windows_x86_64_msvc" version = "0.53.1" @@ -2271,6 +3648,18 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + [[package]] name = "yaml_serde" version = "0.10.7" @@ -2284,6 +3673,115 @@ dependencies = [ "serde", ] +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" diff --git a/INSTALL.md b/INSTALL.md index d9d8db8..24f9068 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -71,6 +71,9 @@ distill doctor It should list the plugin under "Plugins" and end with "All good." Then tell the user to open a new session: plugins load when a session starts. +Optional: `distill model pull` downloads a 0.25 GB embedding model so recall also finds +questions asked in different words or another language. Ask the user first; it can run later. + Finally, run `distill ui` once. It opens the web UI in the browser and authorizes it; links the agent hands out later open without asking again. diff --git a/apps/web/CHANGELOG.md b/apps/web/CHANGELOG.md index 3c4b2b5..196664d 100644 --- a/apps/web/CHANGELOG.md +++ b/apps/web/CHANGELOG.md @@ -8,5 +8,7 @@ - Annotations can be added, edited and deleted on the note page; topics can be renamed and merged; sync conflicts can be settled by keeping one copy. - ⌘K palette over notes, topics and tags; light and dark themes following the system setting. +- Duplicate topics: a "可能重复" view on the Topics page with merge and dismiss, a home-page + banner, and "相似的 topic" on topic pages. Needs `distill model pull`. - Pages refetch when the server reports a vault change, so notes saved from an agent appear without a reload. diff --git a/apps/web/README.md b/apps/web/README.md index ef0ef26..9aeacca 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -35,8 +35,8 @@ Design direction and token rules: the | `/` | Most-asked topics, repeated topics without an annotation, recent notes, a banner when files need attention | | `/notes?q=` | Keyword search, or recent notes | | `/notes/$noteId` | The note, its topic and ask count, tags, annotations (add, edit, delete), how to reopen the session | -| `/topics?view=asked\|recent\|undigested` | Every topic | -| `/topics/$topicId` | Timeline of the topic's notes; rename; merge into another topic | +| `/topics?view=asked\|recent\|undigested\|duplicates` | Every topic, or topic pairs that are probably one question | +| `/topics/$topicId` | Timeline of the topic's notes; rename; similar topics; merge into another topic | | `/tags`, `/tags/$tag` | Tags by note count; notes with a tag | | `/stats` | Counts, notes per ISO week (16 weeks), most-asked and unannotated topics, tags, projects | | `/problems` | Sync conflicts (keep one copy) and unreadable files | diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 4dcf28a..57ad8cd 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -1,12 +1,15 @@ import { bridge } from "./bridge"; import type { AnnotationInput } from "./generated/AnnotationInput"; import type { ConflictChoice } from "./generated/ConflictChoice"; +import type { Duplicates } from "./generated/Duplicates"; import type { Merged } from "./generated/Merged"; import type { NoteDetail } from "./generated/NoteDetail"; import type { NoteHit } from "./generated/NoteHit"; import type { NotesQuery } from "./generated/NotesQuery"; +import type { PairDismissal } from "./generated/PairDismissal"; import type { Problems } from "./generated/Problems"; import type { Session } from "./generated/Session"; +import type { SimilarTopic } from "./generated/SimilarTopic"; import type { Stats } from "./generated/Stats"; import type { TagCount } from "./generated/TagCount"; import type { TopicCount } from "./generated/TopicCount"; @@ -42,6 +45,11 @@ export const api = { bridge().request<void>("PUT", `/topics/${enc(id)}`, body), mergeTopic: (id: string, body: TopicMerge) => bridge().request<Merged>("POST", `/topics/${enc(id)}/merge`, body), + similarTopics: (id: string) => + bridge().request<SimilarTopic[]>("GET", `/topics/${enc(id)}/similar`), + duplicates: () => bridge().request<Duplicates>("GET", "/duplicates"), + dismissDuplicate: (body: PairDismissal) => + bridge().request<void>("POST", "/duplicates/dismiss", body), tags: () => bridge().request<TagCount[]>("GET", "/tags"), stats: () => bridge().request<Stats>("GET", "/stats"), problems: () => bridge().request<Problems>("GET", "/problems"), diff --git a/apps/web/src/api/generated/Duplicates.ts b/apps/web/src/api/generated/Duplicates.ts new file mode 100644 index 0000000..51c5de1 --- /dev/null +++ b/apps/web/src/api/generated/Duplicates.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { TopicPair } from "./TopicPair"; + +export type Duplicates = { +/** + * False until `distill model pull` has run; `pairs` is then empty. + */ +model_installed: boolean, pairs: Array<TopicPair>, }; diff --git a/apps/web/src/api/generated/PairDismissal.ts b/apps/web/src/api/generated/PairDismissal.ts new file mode 100644 index 0000000..b758c39 --- /dev/null +++ b/apps/web/src/api/generated/PairDismissal.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type PairDismissal = { a: string, b: string, }; diff --git a/apps/web/src/api/generated/RecallResult.ts b/apps/web/src/api/generated/RecallResult.ts index 8d85ae0..0e439de 100644 --- a/apps/web/src/api/generated/RecallResult.ts +++ b/apps/web/src/api/generated/RecallResult.ts @@ -6,4 +6,8 @@ export type RecallResult = { topics: Array<RecallTopic>, /** * Every tag in use, so the agent can reuse one instead of inventing a near-duplicate. */ -tags: Array<TagCount>, }; +tags: Array<TagCount>, +/** + * Whether embeddings took part. False until `distill model pull` has run. + */ +semantic: boolean, }; diff --git a/apps/web/src/api/generated/Session.ts b/apps/web/src/api/generated/Session.ts index 9834900..1ee995e 100644 --- a/apps/web/src/api/generated/Session.ts +++ b/apps/web/src/api/generated/Session.ts @@ -4,4 +4,8 @@ export type Session = { version: string, vault: string, /** * `http://distill.localhost:<port>`, the address links point at. */ -origin: string, }; +origin: string, +/** + * Whether `distill model pull` has run; enables similar-topic suggestions. + */ +embedding_model: boolean, }; diff --git a/apps/web/src/api/generated/SimilarTopic.ts b/apps/web/src/api/generated/SimilarTopic.ts new file mode 100644 index 0000000..302066a --- /dev/null +++ b/apps/web/src/api/generated/SimilarTopic.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { TopicRef } from "./TopicRef"; + +export type SimilarTopic = { topic: TopicRef, similarity: number, ask_count: number, }; diff --git a/apps/web/src/api/generated/TopicPair.ts b/apps/web/src/api/generated/TopicPair.ts new file mode 100644 index 0000000..721e51f --- /dev/null +++ b/apps/web/src/api/generated/TopicPair.ts @@ -0,0 +1,8 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { TopicRef } from "./TopicRef"; + +export type TopicPair = { a: TopicRef, b: TopicRef, +/** + * Cosine similarity of the two topics' mean note vectors, 0 to 1. + */ +similarity: number, a_count: number, b_count: number, }; diff --git a/apps/web/src/api/queries.ts b/apps/web/src/api/queries.ts index ac4e08a..b1623e2 100644 --- a/apps/web/src/api/queries.ts +++ b/apps/web/src/api/queries.ts @@ -21,6 +21,9 @@ export const q = { note: (id: string) => queryOptions({ queryKey: ["note", id], queryFn: () => api.note(id) }), topics: () => queryOptions({ queryKey: ["topics"], queryFn: api.topics }), topic: (id: string) => queryOptions({ queryKey: ["topic", id], queryFn: () => api.topic(id) }), + similarTopics: (id: string) => + queryOptions({ queryKey: ["similar", id], queryFn: () => api.similarTopics(id) }), + duplicates: () => queryOptions({ queryKey: ["duplicates"], queryFn: api.duplicates }), tags: () => queryOptions({ queryKey: ["tags"], queryFn: api.tags }), stats: () => queryOptions({ queryKey: ["stats"], queryFn: api.stats }), problems: () => queryOptions({ queryKey: ["problems"], queryFn: api.problems }), diff --git a/apps/web/src/components/Duplicates.tsx b/apps/web/src/components/Duplicates.tsx new file mode 100644 index 0000000..311ab3d --- /dev/null +++ b/apps/web/src/components/Duplicates.tsx @@ -0,0 +1,82 @@ +import { useQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import { GitMerge, X } from "lucide-react"; +import { api } from "../api/client"; +import type { TopicPair } from "../api/generated/TopicPair"; +import { q, useEdit } from "../api/queries"; +import { Button, Empty, ErrorBox, Loading } from "./ui"; + +/** Topic pairs the embedding model thinks are one question, each with merge and dismiss. */ +export function DuplicateList() { + const dup = useQuery(q.duplicates()); + if (dup.isPending) { + return <Loading />; + } + if (dup.isError) { + return <ErrorBox error={dup.error} />; + } + if (!dup.data.model_installed) { + return ( + <Empty> + 找重复 topic 需要本机的 embedding 模型。在终端运行{" "} + <code className="font-mono">distill model pull</code>(约 0.25 GB)。 + </Empty> + ); + } + if (dup.data.pairs.length === 0) { + return <Empty>没有看起来是同一个问题的 topic。</Empty>; + } + return ( + <ul className="space-y-3"> + {dup.data.pairs.map((pair) => ( + <PairRow key={`${pair.a.topic_id}:${pair.b.topic_id}`} pair={pair} /> + ))} + </ul> + ); +} + +function PairRow({ pair }: { pair: TopicPair }) { + const merge = useEdit(() => api.mergeTopic(pair.a.topic_id, { into: pair.b.topic_id })); + const dismiss = useEdit(() => api.dismissDuplicate({ a: pair.a.topic_id, b: pair.b.topic_id })); + const busy = merge.isPending || dismiss.isPending; + return ( + <li className="rounded-panel border border-line bg-panel p-4"> + <div className="text-xs text-ink-faint">相似度 {Math.round(pair.similarity * 100)}%</div> + <div className="mt-2 grid gap-1 text-sm sm:grid-cols-[1fr_auto_1fr] sm:items-center sm:gap-3"> + <TopicLink id={pair.a.topic_id} label={pair.a.label} count={pair.a_count} /> + <span className="text-ink-faint" aria-hidden> + → + </span> + <TopicLink id={pair.b.topic_id} label={pair.b.label} count={pair.b_count} /> + </div> + <div className="mt-3 flex flex-wrap gap-2"> + <Button variant="primary" disabled={busy} onClick={() => merge.mutate(undefined)}> + <GitMerge className="size-4" aria-hidden /> + 合并进右边 + </Button> + <Button disabled={busy} onClick={() => dismiss.mutate(undefined)}> + <X className="size-4" aria-hidden /> + 不是同一个问题 + </Button> + </div> + {(merge.error || dismiss.error) && ( + <div className="mt-3"> + <ErrorBox error={merge.error ?? dismiss.error} /> + </div> + )} + </li> + ); +} + +function TopicLink({ id, label, count }: { id: string; label: string; count: number }) { + return ( + <Link + to="/topics/$topicId" + params={{ topicId: id }} + className="min-w-0 text-ink hover:text-brand-ink" + > + <span className="line-clamp-2">{label}</span> + <span className="text-xs text-ink-faint">{count} 条 note</span> + </Link> + ); +} diff --git a/apps/web/src/pages/HomePage.tsx b/apps/web/src/pages/HomePage.tsx index b981359..f0df5cc 100644 --- a/apps/web/src/pages/HomePage.tsx +++ b/apps/web/src/pages/HomePage.tsx @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; -import { ArrowRight, TriangleAlert } from "lucide-react"; +import { ArrowRight, GitMerge, TriangleAlert } from "lucide-react"; import { q } from "../api/queries"; import { NoteList } from "../components/NoteList"; import { TopicRows } from "../components/TopicRows"; @@ -13,6 +13,8 @@ export function HomePage() { const stats = useQuery(q.stats()); const recent = useQuery(q.notes({ limit: RECENT })); const problems = useQuery(q.problems()); + const duplicates = useQuery(q.duplicates()); + const duplicateCount = duplicates.data?.pairs.length ?? 0; if (stats.isPending || recent.isPending) { return <Loading />; @@ -54,6 +56,17 @@ export function HomePage() { <ArrowRight className="ml-auto size-4" aria-hidden /> </Link> )} + {duplicateCount > 0 && ( + <Link + to="/topics" + search={{ view: "duplicates" }} + className="mb-8 flex items-center gap-3 rounded-panel border border-warning/40 bg-warning-soft px-4 py-3 text-sm text-warning" + > + <GitMerge className="size-4 shrink-0" aria-hidden /> + {duplicateCount} 对 topic 看起来是同一个问题,合并后提问次数才准。 + <ArrowRight className="ml-auto size-4" aria-hidden /> + </Link> + )} <Section title="问得最多" action={<More to="/topics" />}> {repeated.length > 0 ? ( <TopicRows topics={repeated.slice(0, TOP)} /> diff --git a/apps/web/src/pages/TopicPage.tsx b/apps/web/src/pages/TopicPage.tsx index 578b6eb..5ad108b 100644 --- a/apps/web/src/pages/TopicPage.tsx +++ b/apps/web/src/pages/TopicPage.tsx @@ -54,6 +54,7 @@ export function TopicPage() { </ul> </Section> )} + <Similar topic={t} /> <Section title="合并"> <Merge topic={t} /> </Section> @@ -203,3 +204,52 @@ function Merge({ topic }: { topic: TopicDetail }) { </div> ); } + +function Similar({ topic }: { topic: TopicDetail }) { + const session = useQuery(q.session()); + const similar = useQuery({ + ...q.similarTopics(topic.topic_id), + enabled: session.data?.embedding_model === true, + }); + const navigate = useNavigate(); + const merge = useEdit((into: string) => api.mergeTopic(topic.topic_id, { into })); + if (!similar.data || similar.data.length === 0) { + return null; + } + return ( + <Section title="相似的 topic"> + <ul className="border-t border-line"> + {similar.data.map((s) => ( + <li key={s.topic.topic_id} className="flex items-center gap-3 border-b border-line py-2"> + <Link + to="/topics/$topicId" + params={{ topicId: s.topic.topic_id }} + className="min-w-0 flex-1 truncate text-ink hover:text-brand-ink" + > + {s.topic.label} + </Link> + <span className="shrink-0 text-xs text-ink-faint tabular-nums"> + {Math.round(s.similarity * 100)}% + </span> + <AskCount count={s.ask_count} /> + <Button + disabled={merge.isPending} + onClick={() => { + if (window.confirm(`把「${topic.label}」合并进「${s.topic.label}」?`)) { + merge.mutate(s.topic.topic_id, { + onSuccess: (merged) => + navigate({ to: "/topics/$topicId", params: { topicId: merged.topic_id } }), + }); + } + }} + > + <GitMerge className="size-4" aria-hidden /> + 合并进去 + </Button> + </li> + ))} + </ul> + {merge.error && <ErrorBox error={merge.error} />} + </Section> + ); +} diff --git a/apps/web/src/pages/TopicsPage.tsx b/apps/web/src/pages/TopicsPage.tsx index e8264fd..7c0fbda 100644 --- a/apps/web/src/pages/TopicsPage.tsx +++ b/apps/web/src/pages/TopicsPage.tsx @@ -2,15 +2,17 @@ import { useQuery } from "@tanstack/react-query"; import { useNavigate, useSearch } from "@tanstack/react-router"; import type { TopicCount } from "../api/generated/TopicCount"; import { q } from "../api/queries"; +import { DuplicateList } from "../components/Duplicates"; import { TopicRows } from "../components/TopicRows"; import { cx, ErrorBox, Loading, PageHeader } from "../components/ui"; -export type TopicView = "asked" | "recent" | "undigested"; +export type TopicView = "asked" | "recent" | "undigested" | "duplicates"; const VIEWS: { value: TopicView; label: string }[] = [ { value: "asked", label: "问得最多" }, { value: "recent", label: "最近问过" }, { value: "undigested", label: "重问过、没有 annotation" }, + { value: "duplicates", label: "可能重复" }, ]; function arrange(topics: TopicCount[], view: TopicView): TopicCount[] { @@ -20,6 +22,7 @@ function arrange(topics: TopicCount[], view: TopicView): TopicCount[] { case "recent": return [...topics].sort((a, b) => b.last_asked.localeCompare(a.last_asked)); case "undigested": + case "duplicates": return topics.filter((t) => t.ask_count >= 2 && !t.annotated); } } @@ -53,7 +56,14 @@ export function TopicsPage() { </button> ))} </div> - {topics.isPending ? ( + {view === "duplicates" ? ( + <> + <p className="mb-4 text-sm text-ink-faint"> + 同一个问题被存成了不同 topic 时,提问次数会被拆散。合并后次数加在一起,note 文件不变。 + </p> + <DuplicateList /> + </> + ) : topics.isPending ? ( <Loading /> ) : topics.isError ? ( <ErrorBox error={topics.error} /> diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index d9053ca..09c6202 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -15,7 +15,7 @@ const root = createRootRoute({ notFoundComponent: () => <Empty>这个页面不存在。</Empty>, }); -const TOPIC_VIEWS: TopicView[] = ["asked", "recent", "undigested"]; +const TOPIC_VIEWS: TopicView[] = ["asked", "recent", "undigested", "duplicates"]; const routeTree = root.addChildren([ createRoute({ getParentRoute: () => root, path: "/", component: HomePage }), diff --git a/crates/distill-cli/CHANGELOG.md b/crates/distill-cli/CHANGELOG.md index c5e987d..81c6562 100644 --- a/crates/distill-cli/CHANGELOG.md +++ b/crates/distill-cli/CHANGELOG.md @@ -13,5 +13,10 @@ - `distill ui`: local web UI server at `http://distill.localhost:<ui.port>` with a session cookie from a one-time link, loopback-only listening, Host and Origin checks, a JSON API and server-sent change events. `distill ui stop` stops it. See `docs/web-ui.md`. +- `distill model pull` downloads the embedding model and embeds existing notes; + `distill model status` reports it. `distill recall`, `distill_recall` and the web UI use it + when installed; `distill_recall` returns `semantic`. `doctor` shows whether it is installed. +- Web API: `GET /api/topics/{id}/similar`, `GET /api/duplicates`, + `POST /api/duplicates/dismiss`. - `distill mcp` starts the web UI in the background before returning note links, unless `ui.autostart` is `false`. `distill_recall` and `distill_search` notes now carry `url`. diff --git a/crates/distill-cli/README.md b/crates/distill-cli/README.md index d4611dc..56d252a 100644 --- a/crates/distill-cli/README.md +++ b/crates/distill-cli/README.md @@ -30,6 +30,7 @@ existing vault. | `stats` | Notes, topics, repeats, tags, weeks, projects | | `tags` | Tags in use with note counts | | `annotate <note-id> <text…>` | Add your own understanding to a note | +| `model pull \| status` | Download the embedding model recall uses for reworded questions (about 0.25 GB), or check it ([docs/recall.md](../../docs/recall.md)) | | `reindex` | Rebuild this device's index from the vault | | `vault show \| use <dir> \| move <dir>` | Show, switch, or copy-and-switch the vault | | `config list \| get <key> \| set <key> <value>` | Settable keys: `ui.port`, `ui.autostart`, `suggest.enabled` | diff --git a/crates/distill-cli/src/main.rs b/crates/distill-cli/src/main.rs index 1647101..69e73e6 100644 --- a/crates/distill-cli/src/main.rs +++ b/crates/distill-cli/src/main.rs @@ -11,6 +11,7 @@ use anyhow::{Context, Result, bail}; use clap::{Parser, Subcommand}; use distill_core::Distill; use distill_core::config::{Dirs, SETTABLE_KEYS}; +use distill_core::embed::{self, Embedder}; use distill_core::ops::{InitOptions, SaveRequest, default_vault_path, expand_vault_arg, init}; use serde::Serialize; @@ -75,6 +76,9 @@ enum Command { #[arg(required = true, num_args = 1..)] text: Vec<String>, }, + /// Download or inspect the local embedding model used by recall. + #[command(subcommand)] + Model(ModelCommand), /// Rebuild this device's index from the vault. Reindex, /// Show, switch or move the vault. @@ -122,6 +126,14 @@ enum HookEvent { UserPromptSubmit, } +#[derive(Subcommand)] +enum ModelCommand { + /// Download the embedding model (about 0.25 GB) into this device's data directory. + Pull, + /// Whether the model is installed, and where. + Status, +} + #[derive(Subcommand)] enum VaultCommand { Show, @@ -188,7 +200,7 @@ fn run(cli: Cli) -> Result<()> { emit(json, &result, render::saved) } Command::Recall { question, limit } => { - let result = Distill::open()?.index.recall(&question.join(" "), limit)?; + let result = Distill::open()?.recall(&question.join(" "), limit)?; emit(json, &result, render::recall) } Command::Search { query, tag, limit } => { @@ -205,6 +217,7 @@ fn run(cli: Cli) -> Result<()> { format!("Annotation saved to {}", path.display()) }) } + Command::Model(cmd) => model(cmd, json), Command::Reindex => { let mut d = Distill::open()?; let report = d.index.rebuild()?; @@ -248,6 +261,39 @@ fn run(cli: Cli) -> Result<()> { } } +fn model(cmd: ModelCommand, json: bool) -> Result<()> { + let dirs = Dirs::discover()?; + if let ModelCommand::Pull = cmd { + if !json { + println!("Downloading {} …", embed::MODEL_REPO); + } + Embedder::pull(&dirs, !json)?; + // Embed the notes already in the vault now, so the first recall is fast. + let d = Distill::open_in(dirs.clone())?; + if let Some(embedder) = Embedder::load(&dirs)? { + d.index.embed_missing(&embedder)?; + } + } + let installed = Embedder::installed(&dirs); + let dir = Embedder::models_dir(&dirs); + let info = serde_json::json!({ + "installed": installed, + "model": embed::MODEL_REPO, + "dir": dir, + }); + emit(json, &info, |_| { + if installed { + format!( + "Embedding model {} is installed in {}.\nRecall matches reworded questions.", + embed::MODEL_REPO, + dir.display() + ) + } else { + "The embedding model is not installed; recall uses keywords only.\nRun `distill model pull` to download it (about 0.25 GB).".into() + } + }) +} + fn vault(cmd: VaultCommand, json: bool) -> Result<()> { let mut d = Distill::open()?; match cmd { diff --git a/crates/distill-cli/src/mcp.rs b/crates/distill-cli/src/mcp.rs index fedef92..0017286 100644 --- a/crates/distill-cli/src/mcp.rs +++ b/crates/distill-cli/src/mcp.rs @@ -69,7 +69,7 @@ impl DistillServer { ) -> Result<CallToolResult, McpError> { respond(|| { let d = Distill::open()?; - let result = d.index.recall(&p.question, p.limit.unwrap_or(5))?; + let result = d.recall(&p.question, p.limit.unwrap_or(5))?; let warnings = if result.topics.is_empty() { Vec::new() } else { @@ -87,7 +87,12 @@ impl DistillServer { }) }) .collect(); - Ok(json!({ "topics": topics, "tags": result.tags, "warnings": warnings })) + Ok(json!({ + "topics": topics, + "tags": result.tags, + "semantic": result.semantic, + "warnings": warnings, + })) }) } diff --git a/crates/distill-cli/src/render.rs b/crates/distill-cli/src/render.rs index 672ac18..77bba6d 100644 --- a/crates/distill-cli/src/render.rs +++ b/crates/distill-cli/src/render.rs @@ -167,6 +167,14 @@ pub fn doctor(r: &DoctorReport) -> String { ) }) .collect::<Vec<_>>(); + out.push_str(&format!( + "Embedding model: {}\n", + if r.embedding_model { + "installed (recall matches reworded questions)" + } else { + "not installed; recall uses keywords only. Run `distill model pull` (about 0.25 GB)" + } + )); out.push_str(&format!( "Plugins: {}\n", if plugins.is_empty() { diff --git a/crates/distill-cli/src/ui/api.rs b/crates/distill-cli/src/ui/api.rs index 90b07e5..54ba081 100644 --- a/crates/distill-cli/src/ui/api.rs +++ b/crates/distill-cli/src/ui/api.rs @@ -10,10 +10,12 @@ use axum::response::{IntoResponse, Response}; use axum::routing::{get, post, put}; use axum::{Json, Router}; use distill_core::Distill; +use distill_core::embed::Embedder; use distill_core::index::{ - Conflict, InvalidFile, NoteDetail, NoteHit, RecallResult, Stats, TagCount, TopicCount, - TopicDetail, + Conflict, InvalidFile, NoteDetail, NoteHit, RecallResult, SimilarTopic, Stats, TagCount, + TopicCount, TopicDetail, }; +use distill_core::ops::Duplicates; use serde::{Deserialize, Serialize}; use super::server::AppState; @@ -34,6 +36,8 @@ pub struct Session { pub vault: String, /// `http://distill.localhost:<port>`, the address links point at. pub origin: String, + /// Whether `distill model pull` has run; enables similar-topic suggestions. + pub embedding_model: bool, } #[derive(Debug, Deserialize, ts_rs::TS)] @@ -75,6 +79,13 @@ pub struct TopicMerge { pub into: String, } +#[derive(Debug, Deserialize, ts_rs::TS)] +#[ts(export)] +pub struct PairDismissal { + pub a: String, + pub b: String, +} + #[derive(Debug, Serialize, ts_rs::TS)] #[ts(export)] pub struct Merged { @@ -106,6 +117,7 @@ pub struct ApiErrorBody { } const DEFAULT_LIMIT: usize = 50; +const SIMILAR_LIMIT: usize = 5; const MAX_LIMIT: usize = 500; /// Routes under `/api`, all behind the session check in `server.rs`. @@ -122,6 +134,9 @@ pub fn routes() -> Router<AppState> { .route("/topics", get(topics)) .route("/topics/{id}", get(topic).put(rename_topic)) .route("/topics/{id}/merge", post(merge_topic)) + .route("/topics/{id}/similar", get(similar_topics)) + .route("/duplicates", get(duplicates)) + .route("/duplicates/dismiss", post(dismiss_duplicate)) .route("/tags", get(tags)) .route("/stats", get(stats)) .route("/recall", get(recall)) @@ -143,6 +158,7 @@ async fn session(State(s): State<AppState>) -> Result<Json<Session>, ApiError> { version: env!("CARGO_PKG_VERSION").into(), vault: d.vault.root().display().to_string(), origin: d.config.ui_origin(), + embedding_model: Embedder::installed(&d.dirs), }) }) .await @@ -243,6 +259,25 @@ async fn merge_topic( Ok(Json(merged)) } +async fn similar_topics( + State(s): State<AppState>, + Path(id): Path<String>, +) -> Result<Json<Vec<SimilarTopic>>, ApiError> { + read(&s, move |d| d.similar_topics(&id, SIMILAR_LIMIT)).await +} + +async fn duplicates(State(s): State<AppState>) -> Result<Json<Duplicates>, ApiError> { + read(&s, |d| d.duplicate_topics()).await +} + +async fn dismiss_duplicate( + State(s): State<AppState>, + Json(body): Json<PairDismissal>, +) -> Result<StatusCode, ApiError> { + write(&s, move |d| d.index.dismiss_pair(&body.a, &body.b)).await?; + Ok(StatusCode::NO_CONTENT) +} + async fn tags(State(s): State<AppState>) -> Result<Json<Vec<TagCount>>, ApiError> { read(&s, |d| d.index.tags()).await } @@ -255,7 +290,7 @@ async fn recall( State(s): State<AppState>, Query(q): Query<RecallQuery>, ) -> Result<Json<RecallResult>, ApiError> { - read(&s, move |d| d.index.recall(&q.q, q.limit.unwrap_or(5))).await + read(&s, move |d| d.recall(&q.q, q.limit.unwrap_or(5))).await } async fn problems(State(s): State<AppState>) -> Result<Json<Problems>, ApiError> { diff --git a/crates/distill-core/CHANGELOG.md b/crates/distill-core/CHANGELOG.md index b70fb55..bc1e49c 100644 --- a/crates/distill-core/CHANGELOG.md +++ b/crates/distill-core/CHANGELOG.md @@ -24,5 +24,11 @@ `delete_annotation` and `resolve_conflict`. Settling a conflict moves the other copies to `<data dir>/discarded/<vault id>/` instead of deleting them. - `ui.autostart` setting (default `true`) and `LocalConfig::ui_origin`. +- Semantic recall: `Distill::recall` fuses keyword ranking with cosine similarity from a local + multilingual embedding model (paraphrase-multilingual-MiniLM-L12-v2, quantized, via + fastembed) when it has been pulled with `Embedder::pull`. Vectors are cached in the index + (migration `0002_embeddings.sql`) and survive `reindex`. +- Duplicate topics: `Distill::duplicate_topics`, `similar_topics` and `Index::dismiss_pair`. +- `DoctorReport::embedding_model`. - TypeScript types for every API view type, generated by ts-rs into `apps/web/src/api/generated` when `cargo test` runs. diff --git a/crates/distill-core/Cargo.toml b/crates/distill-core/Cargo.toml index 36ef187..bcf8478 100644 --- a/crates/distill-core/Cargo.toml +++ b/crates/distill-core/Cargo.toml @@ -8,6 +8,7 @@ license.workspace = true [dependencies] blake3 = "1.8.7" directories = "6.0.0" +fastembed = { version = "7.1.0", default-features = false, features = ["hf-hub-rustls-tls", "ort-download-binaries-rustls-tls"] } jiff = "0.2.37" regex = "1.13.1" rusqlite = { version = "0.40.2", features = ["bundled"] } diff --git a/crates/distill-core/README.md b/crates/distill-core/README.md index fb6ce1d..215f02d 100644 --- a/crates/distill-core/README.md +++ b/crates/distill-core/README.md @@ -18,7 +18,10 @@ the per-device SQLite index derived from it, and the operations that read and wr | `Distill::resolve_conflict` | Keep one copy of a conflicted file; move the others to `Dirs::discarded_dir` | | `Distill::move_vault` / `use_vault` | Copy the vault elsewhere, or switch to another vault | | `Distill::doctor` | Paths, installed plugin, conflicts, unreadable files | -| `Index::recall` | Topics that resemble a question, with all their notes, annotations and every tag in use | +| `Distill::recall` | Topics that resemble a question, with all their notes, annotations and every tag in use; keyword and embedding rankings fused when the model is installed ([docs/recall.md](../../docs/recall.md)) | +| `Index::recall` | Keyword-only recall | +| `Distill::duplicate_topics` / `similar_topics`, `Index::dismiss_pair` | Topic pairs that are probably one question; topics close to one topic; hide a pair | +| `embed::Embedder` | The local embedding model: `pull`, `installed`, `load` (cached per process), `embed` | | `Index::search` | Keyword search, optionally filtered by tag | | `Index::stats` / `tags` | Counts by topic, tag, ISO week and project | | `Index::note_detail` / `topic_detail` / `topics` | What the web UI's note, topic and topic-list pages show | @@ -48,8 +51,12 @@ are rejected. ## Limits -- Recall ranks every note in memory on CJK bigrams and words, weighted by IDF. It is meant for - a personal vault of thousands of notes; embeddings replace it later. +- Recall scores every note in memory: CJK bigrams and words weighted by IDF, plus cosine + similarity of 384-dimension vectors when the model is installed. It is meant for a personal + vault of thousands of notes. +- The embedding model is downloaded only by `Embedder::pull` (about 0.25 GB into + `<data dir>/models`). Building this crate downloads ONNX Runtime (fastembed's prebuilt + binaries) once. - Search uses a trigram FTS index for pieces of three or more characters and `LIKE` for shorter ones. - The index is safe for concurrent processes (WAL, 5 s busy timeout). The vault is safe for diff --git a/crates/distill-core/migrations/0002_embeddings.sql b/crates/distill-core/migrations/0002_embeddings.sql new file mode 100644 index 0000000..b9dffa5 --- /dev/null +++ b/crates/distill-core/migrations/0002_embeddings.sql @@ -0,0 +1,18 @@ +-- Note vectors for semantic recall (P4). Keyed by model and by the hash of the embedded +-- text, not by file: `distill reindex` clears the file tables but keeps these, so a rebuild +-- does not re-run the model. Rows for texts no longer in the vault are pruned when missing +-- vectors are computed. +CREATE TABLE embedding ( + model TEXT NOT NULL, + text_hash TEXT NOT NULL, + vector BLOB NOT NULL, + PRIMARY KEY (model, text_hash) +); + +-- Topic pairs the user marked "not the same question", so they are not suggested again. +-- Device-local: a dismissal is a hint, not vault data. topic_a < topic_b. +CREATE TABLE dismissed_pair ( + topic_a TEXT NOT NULL, + topic_b TEXT NOT NULL, + PRIMARY KEY (topic_a, topic_b) +); diff --git a/crates/distill-core/src/embed.rs b/crates/distill-core/src/embed.rs new file mode 100644 index 0000000..bf45185 --- /dev/null +++ b/crates/distill-core/src/embed.rs @@ -0,0 +1,173 @@ +//! Local embeddings for recall and for spotting duplicate topics (spec D3, P4). +//! +//! The model runs in-process on ONNX Runtime through fastembed. Nothing is downloaded +//! implicitly: `distill model pull` fetches the model into `<data dir>/models` once, and +//! until then every caller falls back to keyword recall. The model never sees anything but +//! note titles and questions already on this device. + +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; + +use fastembed::{EmbeddingModel, TextEmbedding, TextInitOptions}; + +use crate::config::Dirs; +use crate::error::{Error, IoContext, Result}; + +/// Stored with every vector, so switching models later re-embeds instead of mixing spaces. +pub const MODEL_KEY: &str = "paraphrase-multilingual-minilm-l12-v2-q"; +/// Hugging Face repository the model comes from. +pub const MODEL_REPO: &str = "Qdrant/paraphrase-multilingual-MiniLM-L12-v2-onnx-Q"; +const MODEL: EmbeddingModel = EmbeddingModel::ParaphraseMLMiniLML12V2Q; +const BATCH: usize = 32; + +pub struct Embedder { + model: Mutex<TextEmbedding>, +} + +/// One loaded model per process: loading takes a few hundred milliseconds, and the MCP +/// server and web server answer many calls. +static LOADED: Mutex<Option<(PathBuf, Arc<Embedder>)>> = Mutex::new(None); + +impl Embedder { + pub fn models_dir(dirs: &Dirs) -> PathBuf { + dirs.data_dir.join("models") + } + + fn ready_marker(dirs: &Dirs) -> PathBuf { + Self::models_dir(dirs).join(format!("{MODEL_KEY}.ready")) + } + + /// Whether `distill model pull` has completed on this device. + pub fn installed(dirs: &Dirs) -> bool { + Self::ready_marker(dirs).is_file() + } + + /// Downloads the model (about 0.25 GB) unless it is already cached, then marks it ready. + pub fn pull(dirs: &Dirs, show_progress: bool) -> Result<()> { + let dir = Self::models_dir(dirs); + std::fs::create_dir_all(&dir).at(&dir)?; + let embedder = Self::init(dirs, show_progress)?; + embedder.embed(&["distill".to_string()])?; + let marker = Self::ready_marker(dirs); + std::fs::write(&marker, format!("{MODEL_REPO}\n")).at(&marker)?; + Ok(()) + } + + /// The model, when it has been pulled; `None` means callers use keyword recall. + pub fn load(dirs: &Dirs) -> Result<Option<Arc<Self>>> { + if !Self::installed(dirs) { + return Ok(None); + } + let dir = Self::models_dir(dirs); + let mut loaded = LOADED + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some((path, embedder)) = loaded.as_ref() + && *path == dir + { + return Ok(Some(Arc::clone(embedder))); + } + let embedder = Arc::new(Self::init(dirs, false)?); + *loaded = Some((dir, Arc::clone(&embedder))); + Ok(Some(embedder)) + } + + fn init(dirs: &Dirs, show_progress: bool) -> Result<Self> { + let options = TextInitOptions::new(MODEL) + .with_cache_dir(Self::models_dir(dirs)) + .with_show_download_progress(show_progress); + let model = TextEmbedding::try_new(options).map_err(|source| Error::Embedding { + action: format!( + "loading {MODEL_REPO} from {}", + Self::models_dir(dirs).display() + ), + source, + })?; + Ok(Self { + model: Mutex::new(model), + }) + } + + /// Unit-length vectors, one per text, in order. + pub fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>> { + let mut model = self + .model + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let vectors = model + .embed(texts, Some(BATCH)) + .map_err(|source| Error::Embedding { + action: format!("embedding {} text(s)", texts.len()), + source, + })?; + Ok(vectors.into_iter().map(normalized).collect()) + } +} + +impl Embedder { + pub fn embed_one(&self, text: &str) -> Result<Vec<f32>> { + self.embed(&[text.to_string()])? + .pop() + .ok_or_else(|| Error::Embedding { + action: "embedding one text".into(), + source: fastembed::Error::Other("the model returned no vector".into()), + }) + } +} + +fn normalized(mut v: Vec<f32>) -> Vec<f32> { + let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt(); + if norm > 0.0 { + v.iter_mut().for_each(|x| *x /= norm); + } + v +} + +/// Cosine similarity of two unit-length vectors. +pub fn cosine(a: &[f32], b: &[f32]) -> f32 { + a.iter().zip(b).map(|(x, y)| x * y).sum() +} + +/// The mean direction of several unit vectors, itself unit length. +pub fn centroid<'a>(vectors: impl IntoIterator<Item = &'a [f32]>) -> Option<Vec<f32>> { + let mut sum: Option<Vec<f32>> = None; + for v in vectors { + match sum.as_mut() { + None => sum = Some(v.to_vec()), + Some(s) => s.iter_mut().zip(v).for_each(|(a, b)| *a += b), + } + } + sum.map(normalized) +} + +/// What a note is embedded as: the question it answers, not the answer. +pub fn note_text(title: &str, question: &str) -> String { + format!("{title}\n{question}") +} + +/// Vectors are stored as little-endian `f32` bytes. +pub fn to_blob(v: &[f32]) -> Vec<u8> { + v.iter().flat_map(|x| x.to_le_bytes()).collect() +} + +pub fn from_blob(bytes: &[u8]) -> Vec<f32> { + bytes + .chunks_exact(4) + .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn vectors_round_trip_and_compare() { + let a = normalized(vec![3.0, 4.0]); + assert_eq!(from_blob(&to_blob(&a)), a); + assert!((cosine(&a, &a) - 1.0).abs() < 1e-6); + let c = centroid([[1.0, 0.0].as_slice(), [0.0, 1.0].as_slice()]).unwrap_or_default(); + assert!((c[0] - c[1]).abs() < 1e-6); + assert!((cosine(&c, &c) - 1.0).abs() < 1e-6); + } +} diff --git a/crates/distill-core/src/error.rs b/crates/distill-core/src/error.rs index 01b8cc7..8e08e4d 100644 --- a/crates/distill-core/src/error.rs +++ b/crates/distill-core/src/error.rs @@ -91,6 +91,15 @@ pub enum Error { #[error("could not read {path}: {reason}")] InvalidFile { path: PathBuf, reason: String }, + #[error( + "embedding model failed while {action}. Run `distill model pull` to download it again." + )] + Embedding { + action: String, + #[source] + source: fastembed::Error, + }, + #[error("I/O error at {path}")] Io { path: PathBuf, diff --git a/crates/distill-core/src/index/mod.rs b/crates/distill-core/src/index/mod.rs index fef139a..2977d7b 100644 --- a/crates/distill-core/src/index/mod.rs +++ b/crates/distill-core/src/index/mod.rs @@ -8,6 +8,7 @@ mod browse; mod query; mod recall; mod scan; +mod semantic; use std::path::{Path, PathBuf}; use std::sync::LazyLock; @@ -24,9 +25,14 @@ pub use query::{ RecallTopic, Stats, TagCount, TopicCount, WeekCount, }; pub use scan::SyncReport; +pub use semantic::{DUPLICATE_MIN, SimilarTopic, TopicPair}; -static MIGRATIONS: LazyLock<Migrations<'static>> = - LazyLock::new(|| Migrations::new(vec![M::up(include_str!("../../migrations/0001_init.sql"))])); +static MIGRATIONS: LazyLock<Migrations<'static>> = LazyLock::new(|| { + Migrations::new(vec![ + M::up(include_str!("../../migrations/0001_init.sql")), + M::up(include_str!("../../migrations/0002_embeddings.sql")), + ]) +}); pub struct Index { conn: Connection, diff --git a/crates/distill-core/src/index/query.rs b/crates/distill-core/src/index/query.rs index 2e32561..0c948d9 100644 --- a/crates/distill-core/src/index/query.rs +++ b/crates/distill-core/src/index/query.rs @@ -4,7 +4,7 @@ use rusqlite::{Connection, OptionalExtension, params_from_iter}; use serde::Serialize; use super::Index; -use super::recall::{Doc, rank}; +use super::recall::{Doc, fuse, rank}; use crate::error::Result; use crate::model::{Agent, Source}; use crate::tags::resolve_alias; @@ -68,6 +68,8 @@ pub struct RecallResult { pub topics: Vec<RecallTopic>, /// Every tag in use, so the agent can reuse one instead of inventing a near-duplicate. pub tags: Vec<TagCount>, + /// Whether embeddings took part. False until `distill model pull` has run. + pub semantic: bool, } #[derive(Debug, Clone, Serialize, ts_rs::TS)] @@ -169,8 +171,20 @@ impl Index { } /// Topics whose notes resemble `question`, best match first, each with all of its - /// notes and their annotations. + /// notes and their annotations. Keyword matching only; `Distill::recall` adds + /// embeddings when the model is installed. pub fn recall(&self, question: &str, limit: usize) -> Result<RecallResult> { + self.recall_with(question, limit, None) + } + + /// `semantic` holds note ids ranked by embedding similarity, best first, already cut at + /// the similarity floor. It is fused with the keyword ranking. + pub(super) fn recall_with( + &self, + question: &str, + limit: usize, + semantic: Option<&[String]>, + ) -> Result<RecallResult> { let rows = self.rows()?; let by_id: HashMap<&str, &Row> = rows.iter().map(|r| (r.id.as_str(), r)).collect(); let bodies = self.bodies()?; @@ -182,7 +196,11 @@ impl Index { body: bodies.get(&r.id).map_or("", String::as_str), }) .collect(); - let ids = rank(question, &docs); + let keyword = rank(question, &docs); + let ids = match semantic { + Some(semantic) => fuse(&[&keyword, semantic]), + None => keyword, + }; let labels = self.topic_labels()?; let annotations = self.annotations_by_note()?; let mut topics: Vec<String> = Vec::new(); @@ -214,6 +232,7 @@ impl Index { Ok(RecallResult { topics, tags: tag_counts(&rows), + semantic: semantic.is_some(), }) } diff --git a/crates/distill-core/src/index/recall.rs b/crates/distill-core/src/index/recall.rs index 67f4ae9..b8c5f7d 100644 --- a/crates/distill-core/src/index/recall.rs +++ b/crates/distill-core/src/index/recall.rs @@ -3,8 +3,9 @@ //! A reworded question rarely shares three consecutive characters with the old one //! ("加载扩展" vs "加载不了扩展"), so the trigram FTS index used by search misses it. //! Recall instead scores every note in memory on CJK bigrams and whole words, weighted by -//! IDF. A personal vault holds thousands of notes, which this handles in milliseconds; -//! embeddings replace it in P4 (spec D3). +//! IDF. A personal vault holds thousands of notes, which this handles in milliseconds. +//! When the embedding model is installed, this ranking is fused with the semantic one +//! (`semantic.rs`); without it, this is all recall has. use std::collections::{HashMap, HashSet}; @@ -66,6 +67,22 @@ pub(super) fn rank(question: &str, docs: &[Doc]) -> Vec<String> { scored.into_iter().map(|(_, id)| id.to_string()).collect() } +/// Reciprocal rank fusion: each list adds `1 / (K + rank)` for the ids it holds, so an id +/// ranked well by either keyword or embedding recall surfaces, and one ranked well by +/// both comes first. +pub(super) fn fuse(lists: &[&[String]]) -> Vec<String> { + const K: f64 = 60.0; + let mut scores: HashMap<&str, f64> = HashMap::new(); + for list in lists { + for (rank, id) in list.iter().enumerate() { + *scores.entry(id).or_default() += 1.0 / (K + rank as f64 + 1.0); + } + } + let mut ids: Vec<(&str, f64)> = scores.into_iter().collect(); + ids.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(b.0))); + ids.into_iter().map(|(id, _)| id.to_string()).collect() +} + fn is_cjk(c: char) -> bool { matches!(c as u32, 0x3040..=0x30FF | 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xAC00..=0xD7AF | 0xF900..=0xFAFF) @@ -146,6 +163,15 @@ mod tests { assert_eq!(rank("sqlite 扩展为什么加载失败", &docs)[0], "A"); } + #[test] + fn fusion_rewards_agreement() { + let s = |ids: &[&str]| ids.iter().map(|i| i.to_string()).collect::<Vec<_>>(); + let keyword = s(&["A", "B"]); + let semantic = s(&["C", "B"]); + assert_eq!(fuse(&[&keyword, &semantic]), s(&["B", "A", "C"])); + assert_eq!(fuse(&[&keyword, &[]]), keyword); + } + #[test] fn filler_words_alone_do_not_match() { let docs = [ diff --git a/crates/distill-core/src/index/semantic.rs b/crates/distill-core/src/index/semantic.rs new file mode 100644 index 0000000..15d9713 --- /dev/null +++ b/crates/distill-core/src/index/semantic.rs @@ -0,0 +1,263 @@ +//! Embedding-backed reads: recall that finds reworded questions keyword matching misses, +//! and topic pairs that are probably the same question (merge suggestions, spec P4). +//! +//! Vectors live in the index next to everything else and are compared in memory: a +//! personal vault has thousands of notes, which a linear scan over 384-dimension vectors +//! handles in milliseconds. `sqlite-vec` is not used; loading an extension needs `unsafe`, +//! which this workspace forbids. + +use std::collections::{HashMap, HashSet}; + +use rusqlite::params; +use serde::Serialize; + +use super::Index; +use super::browse::TopicRef; +use super::query::{RecallResult, Row}; +use crate::embed::{Embedder, MODEL_KEY, centroid, cosine, from_blob, note_text, to_blob}; +use crate::error::Result; +use crate::fsutil::content_hash; + +/// Below this cosine similarity a note is not a recall candidate. Measured on this model +/// (2026-09-26, 12 Chinese/English pairs): rewordings of one question scored 0.40 to 0.74, +/// different questions on the same subject up to 0.54. The ranges overlap, so recall keeps +/// a low floor and leaves the "same question?" call to the agent, as keyword recall does. +const RECALL_MIN: f32 = 0.4; +/// Topic pairs at or above this are suggested for merging. Above every different-question +/// pair measured; the user confirms each merge or dismisses the pair. +pub const DUPLICATE_MIN: f32 = 0.65; +/// How many semantic hits take part in fusion. +const SEMANTIC_CANDIDATES: usize = 30; + +#[derive(Debug, Clone, Serialize, ts_rs::TS)] +#[ts(export)] +pub struct TopicPair { + pub a: TopicRef, + pub b: TopicRef, + /// Cosine similarity of the two topics' mean note vectors, 0 to 1. + pub similarity: f32, + pub a_count: usize, + pub b_count: usize, +} + +#[derive(Debug, Clone, Serialize, ts_rs::TS)] +#[ts(export)] +pub struct SimilarTopic { + pub topic: TopicRef, + pub similarity: f32, + pub ask_count: usize, +} + +impl Index { + /// Computes vectors for notes that have none under the current model, and drops + /// vectors no note uses any more. Returns how many notes were embedded. + pub fn embed_missing(&self, embedder: &Embedder) -> Result<usize> { + let rows = self.rows()?; + let known: HashSet<String> = { + let mut stmt = self + .conn + .prepare("SELECT text_hash FROM embedding WHERE model = ?1")?; + let hashes = stmt.query_map([MODEL_KEY], |r| r.get(0))?; + hashes.collect::<rusqlite::Result<_>>()? + }; + let mut wanted: HashMap<String, String> = HashMap::new(); + for row in &rows { + let text = note_text(&row.title, &row.question); + wanted.insert(content_hash(text.as_bytes()), text); + } + let missing: Vec<(&String, &String)> = + wanted.iter().filter(|(h, _)| !known.contains(*h)).collect(); + if !missing.is_empty() { + let texts: Vec<String> = missing.iter().map(|(_, t)| (*t).clone()).collect(); + let vectors = embedder.embed(&texts)?; + for ((hash, _), vector) in missing.iter().zip(vectors) { + self.conn.execute( + "INSERT OR REPLACE INTO embedding (model, text_hash, vector) VALUES (?1, ?2, ?3)", + params![MODEL_KEY, hash, to_blob(&vector)], + )?; + } + } + for stale in known.iter().filter(|h| !wanted.contains_key(*h)) { + self.conn.execute( + "DELETE FROM embedding WHERE model = ?1 AND text_hash = ?2", + params![MODEL_KEY, stale], + )?; + } + Ok(missing.len()) + } + + /// Keyword and embedding recall fused. Embeds any notes that lack vectors first. + pub fn recall_semantic( + &self, + question: &str, + limit: usize, + embedder: &Embedder, + ) -> Result<RecallResult> { + self.embed_missing(embedder)?; + let query = embedder.embed_one(question)?; + let vectors = self.note_vectors()?; + let mut scored: Vec<(f32, &String)> = vectors + .iter() + .map(|(id, v)| (cosine(&query, v), id)) + .filter(|(s, _)| *s >= RECALL_MIN) + .collect(); + scored.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.cmp(b.1))); + let semantic: Vec<String> = scored + .into_iter() + .take(SEMANTIC_CANDIDATES) + .map(|(_, id)| id.clone()) + .collect(); + self.recall_with(question, limit, Some(&semantic)) + } + + /// Topic pairs that are probably the same question, most similar first. Pairs the user + /// dismissed are left out. + pub fn duplicate_topics(&self, embedder: &Embedder) -> Result<Vec<TopicPair>> { + self.embed_missing(embedder)?; + let topics = self.topic_vectors()?; + let dismissed = self.dismissed()?; + let mut pairs = Vec::new(); + for (i, a) in topics.iter().enumerate() { + for b in &topics[i + 1..] { + let similarity = cosine(&a.vector, &b.vector); + if similarity < DUPLICATE_MIN || dismissed.contains(&ordered(&a.id, &b.id)) { + continue; + } + // The more-asked topic is `b`, the natural merge target. + let (a, b) = if a.count > b.count { (b, a) } else { (a, b) }; + pairs.push(TopicPair { + a: a.topic_ref(), + b: b.topic_ref(), + similarity, + a_count: a.count, + b_count: b.count, + }); + } + } + pairs.sort_by(|x, y| y.similarity.total_cmp(&x.similarity)); + Ok(pairs) + } + + /// Topics closest to `topic_id`, most similar first, at most `limit`. + pub fn similar_topics( + &self, + topic_id: &str, + limit: usize, + embedder: &Embedder, + ) -> Result<Vec<SimilarTopic>> { + self.embed_missing(embedder)?; + let topic_id = self.resolve_topic(topic_id)?; + let topics = self.topic_vectors()?; + let Some(me) = topics.iter().find(|t| t.id == topic_id) else { + return Ok(Vec::new()); + }; + let mut out: Vec<SimilarTopic> = topics + .iter() + .filter(|t| t.id != topic_id) + .map(|t| SimilarTopic { + similarity: cosine(&me.vector, &t.vector), + ask_count: t.count, + topic: t.topic_ref(), + }) + .filter(|s| s.similarity >= RECALL_MIN) + .collect(); + out.sort_by(|x, y| y.similarity.total_cmp(&x.similarity)); + out.truncate(limit); + Ok(out) + } + + /// Records that two topics are not the same question. + pub fn dismiss_pair(&self, a: &str, b: &str) -> Result<()> { + let (a, b) = ordered(a, b); + self.conn.execute( + "INSERT OR IGNORE INTO dismissed_pair (topic_a, topic_b) VALUES (?1, ?2)", + params![a, b], + )?; + Ok(()) + } + + fn dismissed(&self) -> Result<HashSet<(String, String)>> { + let mut stmt = self + .conn + .prepare("SELECT topic_a, topic_b FROM dismissed_pair")?; + let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?; + Ok(rows.collect::<rusqlite::Result<_>>()?) + } + + /// Note id to vector, for every note that has one. + fn note_vectors(&self) -> Result<HashMap<String, Vec<f32>>> { + let by_hash = self.vectors_by_hash()?; + Ok(self + .rows()? + .into_iter() + .filter_map(|r| { + let hash = content_hash(note_text(&r.title, &r.question).as_bytes()); + by_hash.get(&hash).map(|v| (r.id, v.clone())) + }) + .collect()) + } + + fn vectors_by_hash(&self) -> Result<HashMap<String, Vec<f32>>> { + let mut stmt = self + .conn + .prepare("SELECT text_hash, vector FROM embedding WHERE model = ?1")?; + let rows = stmt.query_map([MODEL_KEY], |r| { + Ok((r.get::<_, String>(0)?, from_blob(&r.get::<_, Vec<u8>>(1)?))) + })?; + Ok(rows.collect::<rusqlite::Result<_>>()?) + } + + fn topic_vectors(&self) -> Result<Vec<TopicVector>> { + let by_hash = self.vectors_by_hash()?; + let labels = self.topic_labels()?; + let mut notes: HashMap<String, Vec<&Row>> = HashMap::new(); + let rows = self.rows()?; + for row in &rows { + notes.entry(row.topic_id.clone()).or_default().push(row); + } + let mut out: Vec<TopicVector> = notes + .into_iter() + .filter_map(|(id, rows)| { + let vectors: Vec<&Vec<f32>> = rows + .iter() + .filter_map(|r| { + by_hash.get(&content_hash(note_text(&r.title, &r.question).as_bytes())) + }) + .collect(); + let vector = centroid(vectors.iter().map(|v| v.as_slice()))?; + Some(TopicVector { + label: labels.get(&id).cloned().unwrap_or_default(), + count: rows.len(), + id, + vector, + }) + }) + .collect(); + out.sort_by(|a, b| a.id.cmp(&b.id)); + Ok(out) + } +} + +struct TopicVector { + id: String, + label: String, + count: usize, + vector: Vec<f32>, +} + +impl TopicVector { + fn topic_ref(&self) -> TopicRef { + TopicRef { + topic_id: self.id.clone(), + label: self.label.clone(), + } + } +} + +fn ordered(a: &str, b: &str) -> (String, String) { + if a <= b { + (a.to_string(), b.to_string()) + } else { + (b.to_string(), a.to_string()) + } +} diff --git a/crates/distill-core/src/lib.rs b/crates/distill-core/src/lib.rs index bac68de..5067645 100644 --- a/crates/distill-core/src/lib.rs +++ b/crates/distill-core/src/lib.rs @@ -3,6 +3,7 @@ pub mod config; mod edit; +pub mod embed; pub mod error; mod fsutil; pub mod index; diff --git a/crates/distill-core/src/ops.rs b/crates/distill-core/src/ops.rs index 68c6fa4..cfba205 100644 --- a/crates/distill-core/src/ops.rs +++ b/crates/distill-core/src/ops.rs @@ -7,9 +7,12 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; use crate::config::{Dirs, LocalConfig, UiConfig, ensure_dir, pick_ui_port}; +use crate::embed::Embedder; use crate::error::{Error, IoContext, Result}; use crate::fsutil::{content_hash, now_rfc3339}; -use crate::index::{Conflict, Index, InvalidFile, SyncReport}; +use crate::index::{ + Conflict, Index, InvalidFile, RecallResult, SimilarTopic, SyncReport, TopicPair, +}; use crate::model::{AnnotationMeta, NoteBody, NoteMeta, SCHEMA, Source, TopicMeta}; use crate::redact::redact; use crate::sources::{InstalledPlugin, SourceEnv, installed_plugins}; @@ -427,6 +430,7 @@ impl Distill { index_file: self.dirs.index_file(self.vault.id()), bin_path: self.config.bin_path.clone(), ui_port: self.config.ui.port, + embedding_model: Embedder::installed(&self.dirs), plugins, conflicts, invalid_files, @@ -434,6 +438,45 @@ impl Distill { } } +#[derive(Debug, Clone, Serialize, ts_rs::TS)] +#[ts(export)] +pub struct Duplicates { + /// False until `distill model pull` has run; `pairs` is then empty. + pub model_installed: bool, + pub pairs: Vec<TopicPair>, +} + +/// Recall and topic similarity, with embeddings when the model is installed. +impl Distill { + pub fn recall(&self, question: &str, limit: usize) -> Result<RecallResult> { + match Embedder::load(&self.dirs)? { + Some(embedder) => self.index.recall_semantic(question, limit, &embedder), + None => self.index.recall(question, limit), + } + } + + pub fn duplicate_topics(&self) -> Result<Duplicates> { + Ok(match Embedder::load(&self.dirs)? { + Some(embedder) => Duplicates { + model_installed: true, + pairs: self.index.duplicate_topics(&embedder)?, + }, + None => Duplicates { + model_installed: false, + pairs: Vec::new(), + }, + }) + } + + /// Topics close to `topic_id`; empty without the model. + pub fn similar_topics(&self, topic_id: &str, limit: usize) -> Result<Vec<SimilarTopic>> { + match Embedder::load(&self.dirs)? { + Some(embedder) => self.index.similar_topics(topic_id, limit, &embedder), + None => Ok(Vec::new()), + } + } +} + #[derive(Debug, Clone, Serialize)] pub struct DoctorReport { pub ok: bool, @@ -444,6 +487,9 @@ pub struct DoctorReport { pub index_file: PathBuf, pub bin_path: Option<PathBuf>, pub ui_port: u16, + /// Whether the embedding model is installed. Without it recall uses keywords only; + /// that is not counted as a problem. + pub embedding_model: bool, pub plugins: Vec<InstalledPlugin>, pub conflicts: Vec<Conflict>, pub invalid_files: Vec<InvalidFile>, diff --git a/crates/distill-core/tests/semantic.rs b/crates/distill-core/tests/semantic.rs new file mode 100644 index 0000000..c4580b2 --- /dev/null +++ b/crates/distill-core/tests/semantic.rs @@ -0,0 +1,142 @@ +#![allow(clippy::unwrap_used)] // Test helpers panic on setup failure by design. + +mod common; + +use common::{Setup, open, request, setup}; +use distill_core::embed::Embedder; +use distill_core::ops::SaveRequest; + +fn note(title: &str, question: &str, topic: &str, tag: &str) -> SaveRequest { + let mut req = request(topic, &[], &[tag]); + req.title = title.into(); + req.question = question.into(); + req.conclusion = format!("{title}:结论。"); + req.key_points = vec![]; + req.confirm_new = true; + req +} + +/// Points the setup at a model pulled beforehand with +/// `DISTILL_HOME=<dir> distill model pull`, so tests do not download 0.25 GB. +fn with_model(s: &Setup) -> bool { + let Some(home) = std::env::var_os("DISTILL_TEST_MODEL_HOME") else { + return false; + }; + let pulled = std::path::PathBuf::from(home).join("data/models"); + assert!(pulled.is_dir(), "{} has no pulled model", pulled.display()); + std::fs::create_dir_all(&s.dirs.data_dir).unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(&pulled, Embedder::models_dir(&s.dirs)).unwrap(); + Embedder::installed(&s.dirs) +} + +#[test] +fn without_the_model_recall_uses_keywords() { + let s = setup(); + let mut d = open(&s); + d.save(note( + "git rebase 和 merge 怎么选", + "rebase 还是 merge?", + "new", + "git", + )) + .unwrap(); + let recall = d.recall("git rebase 还是 merge", 5).unwrap(); + assert!(!recall.semantic); + assert_eq!(recall.topics.len(), 1); + let dup = d.duplicate_topics().unwrap(); + assert!(!dup.model_installed && dup.pairs.is_empty()); +} + +/// The P4 exit criterion: recall finds a reworded question that keyword matching misses. +#[test] +#[ignore = "needs a pulled model: DISTILL_TEST_MODEL_HOME=<DISTILL_HOME with `distill model pull` done>"] +fn recall_finds_reworded_questions_keywords_miss() { + let s = setup(); + assert!(with_model(&s), "set DISTILL_TEST_MODEL_HOME"); + let mut d = open(&s); + let git = d + .save(note( + "如何让 Git 忽略已经提交过的文件", + "文件已经提交了,加进 .gitignore 为什么没用?", + "new", + "git", + )) + .unwrap(); + let effect = d + .save(note( + "React StrictMode 为什么让 useEffect 跑两次", + "useEffect 为什么执行两次?", + "new", + "react", + )) + .unwrap(); + d.save(note( + "SQLite WAL 模式下多进程并发写", + "WAL 能不能多进程写?", + "new", + "sqlite", + )) + .unwrap(); + + for (question, expected) in [ + ( + "stop tracking a file that's already in the repo", + &git.topic_id, + ), + ("开发环境下 effect 执行了两遍", &effect.topic_id), + ] { + let keyword = d.index.recall(question, 5).unwrap(); + assert!( + keyword.topics.iter().all(|t| &t.topic_id != expected), + "keyword recall already finds {question:?}; pick a harder rewording" + ); + let semantic = d.recall(question, 5).unwrap(); + assert!(semantic.semantic); + assert_eq!(&semantic.topics[0].topic_id, expected, "{question:?}"); + } +} + +#[test] +#[ignore = "needs a pulled model: DISTILL_TEST_MODEL_HOME=<DISTILL_HOME with `distill model pull` done>"] +fn near_identical_topics_are_suggested_until_dismissed() { + let s = setup(); + assert!(with_model(&s), "set DISTILL_TEST_MODEL_HOME"); + let mut d = open(&s); + let first = d + .save(note( + "Rust 里 Box<dyn Error> 和 anyhow 的区别", + "Box<dyn Error> 和 anyhow 有什么区别?", + "new", + "rust", + )) + .unwrap(); + let again = d + .save(note( + "anyhow 和 Box<dyn Error> 该用哪个", + "错误处理用 anyhow 还是 Box<dyn Error>?", + "new", + "rust", + )) + .unwrap(); + d.save(note( + "git rebase 和 merge 怎么选", + "rebase 还是 merge?", + "new", + "git", + )) + .unwrap(); + + let dup = d.duplicate_topics().unwrap(); + assert_eq!(dup.pairs.len(), 1, "{:?}", dup.pairs); + let ids = [&dup.pairs[0].a.topic_id, &dup.pairs[0].b.topic_id]; + assert!(ids.contains(&&first.topic_id) && ids.contains(&&again.topic_id)); + + let similar = d.similar_topics(&first.topic_id, 3).unwrap(); + assert_eq!(similar[0].topic.topic_id, again.topic_id); + + d.index + .dismiss_pair(&again.topic_id, &first.topic_id) + .unwrap(); + assert!(d.duplicate_topics().unwrap().pairs.is_empty()); +} diff --git a/docs/index.md b/docs/index.md index b69374b..7f0f084 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,6 +10,7 @@ How Distill works today. Why it works this way is in | [development.md](development.md) | Toolchain, repo layout, `mise run check`, testing with a throwaway setup | | [vault-format.md](vault-format.md) | Vault layout and every file format in it; how the index is derived | | [plugin.md](plugin.md) | The Codex / Claude Code plugin: hook, MCP tools, skill, launcher | +| [recall.md](recall.md) | Keyword and semantic recall, the embedding model, duplicate topics | | [web-ui.md](web-ui.md) | `distill ui`: address, background start, security, the JSON API | | [AGENTS.md](AGENTS.md) | Writing rules for every Markdown file | diff --git a/docs/plugin.md b/docs/plugin.md index 240849c..100c471 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -84,7 +84,7 @@ run `distill init`. | Tool | Returns | |---|---| -| `distill_recall` | Matching topics with `ask_count`, their notes (with `url`, `file`, annotations and `reopen`), every tag in use, and `warnings` | +| `distill_recall` | Matching topics with `ask_count`, their notes (with `url`, `file`, annotations and `reopen`), every tag in use, `semantic` (whether embeddings took part, [recall.md](recall.md)), and `warnings` | | `distill_save` | The save result (`note_id`, `topic_id`, `ask_count`, `tags`, `url`, `file`) plus `warnings` | | `distill_search` | Notes matching keywords and/or a tag, with `url` and `file` | | `distill_stats` | The same counts as `distill stats --json` | diff --git a/docs/recall.md b/docs/recall.md new file mode 100644 index 0000000..3c206c6 --- /dev/null +++ b/docs/recall.md @@ -0,0 +1,66 @@ +# Recall and duplicate topics + +Recall answers "have I asked this before?". It is behind `distill recall`, the +`distill_recall` MCP tool and `GET /api/recall`, all through `Distill::recall`. It returns +candidate topics; the agent decides whether one is the same question. + +## Two rankings + +| Ranking | Always on | How | Code | +|---|---|---|---| +| Keyword | Yes | Every note scored on CJK bigrams and words, IDF-weighted, title and question counted double; notes covering less than 25% of the question's weight are dropped | `crates/distill-core/src/index/recall.rs` | +| Semantic | After `distill model pull` | Cosine similarity between the question and each note's `title + question` vector; notes below 0.40 are dropped, the top 30 kept | `crates/distill-core/src/index/semantic.rs` | + +With the model installed the two lists are merged by reciprocal rank fusion +(`1 / (60 + rank)` summed per note), so a note either ranking finds is a candidate and one +both find ranks first. Notes are then grouped into topics, best first. The result's +`semantic` field says whether embeddings took part. + +Keyword search (`distill search`, the notes page) does not use embeddings. + +## The model + +`distill model pull` downloads +[`Qdrant/paraphrase-multilingual-MiniLM-L12-v2-onnx-Q`](https://huggingface.co/Qdrant/paraphrase-multilingual-MiniLM-L12-v2-onnx-Q) +(384 dimensions, about 0.25 GB on disk) into `<data dir>/models` and embeds every note +already in the vault. It runs in-process on ONNX Runtime, which is linked into `distill` +(fastembed with its prebuilt runtime). `HF_HOME` and `HF_ENDPOINT` change where the files +come from and are cached. + +Nothing downloads implicitly. Without the model, recall is keyword-only, duplicate +suggestions are empty, and `distill doctor` says so without counting it as a problem. +`distill model status` reports whether it is installed. + +A process loads the model once, on the first call that needs it (a few hundred ms), and +keeps it; the MCP server and web server reuse it across calls. + +## Vectors + +Vectors live in the index table `embedding`, keyed by model and by the BLAKE3 hash of the +embedded text. Notes that lack a vector are embedded on the next recall or duplicate check; +vectors whose text no longer appears in the vault are deleted then. `distill reindex` leaves +the table alone, so a rebuild does not re-run the model. They are compared in memory, not +with `sqlite-vec`. + +## Duplicate topics + +A topic's vector is the normalized mean of its notes' vectors. Topic pairs at 0.65 or above +are offered as probably the same question: on the web UI's Topics page ("可能重复"), as a +banner on the home page, and as "相似的 topic" on a topic page (0.40 and above, top 5). +Merging writes `merged_into` as described in [vault-format.md](vault-format.md#topic). +"不是同一个问题" records the pair in the index table `dismissed_pair`, on this device only. + +## Thresholds + +Both cut-offs were measured on 12 Chinese and English question pairs (2026-09-26): +rewordings of one question scored 0.40 to 0.74; different questions on the same subject +scored up to 0.54. The ranges overlap, which is why recall uses the low floor and leaves the +judgment to the agent, and merge suggestions use the high one and leave it to the user. +`recall_finds_reworded_questions_keywords_miss` in `crates/distill-core/tests/semantic.rs` +checks two rewordings keyword recall misses. It and the other model tests are `#[ignore]`d; +run them with a pulled model: + +```bash +DISTILL_HOME=/tmp/distill-model distill model pull +DISTILL_TEST_MODEL_HOME=/tmp/distill-model cargo test -p distill-core --test semantic -- --include-ignored +``` diff --git a/docs/web-ui.md b/docs/web-ui.md index 677657e..e75ffb3 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -64,7 +64,7 @@ as `{ "error": "<message>" }` with 400 (invalid input), 401, 403, 404 (unknown i | Route | Returns / does | |---|---| | `GET /api/health` | `Health`; no session needed | -| `GET /api/session` | `Session`: version, vault path, UI origin | +| `GET /api/session` | `Session`: version, vault path, UI origin, whether the embedding model is installed | | `GET /api/notes?q=&tag=&limit=` | `NoteHit[]`: keyword search, or recent notes when `q` is empty | | `GET /api/notes/{id}` | `NoteDetail` | | `POST /api/notes/{id}/annotations` | Adds an annotation (`AnnotationInput`); returns the updated `NoteDetail` | @@ -73,6 +73,9 @@ as `{ "error": "<message>" }` with 400 (invalid input), 401, 403, 404 (unknown i | `GET /api/topics/{id}` | `TopicDetail`; a merged-away id returns the topic it resolves to | | `PUT /api/topics/{id}` | Renames (`TopicRename`) | | `POST /api/topics/{id}/merge` | Merges this topic into `TopicMerge.into`; returns `Merged` | +| `GET /api/topics/{id}/similar` | `SimilarTopic[]`; empty without the embedding model | +| `GET /api/duplicates` | `Duplicates`: topic pairs that are probably one question ([recall.md](recall.md)) | +| `POST /api/duplicates/dismiss` | Stops suggesting a pair (`PairDismissal`) on this device | | `GET /api/tags` | `TagCount[]` | | `GET /api/stats` | `Stats` | | `GET /api/recall?q=` | `RecallResult` | From f721faae7b7016d27396887c2ad68525555040bf Mon Sep 17 00:00:00 2001 From: Yanlin <jackey.dou@gmail.com> Date: Sat, 26 Sep 2026 12:48:53 -0700 Subject: [PATCH 5/7] Note the P3 and P4 decisions in the foundation spec Records the answers to Q5, Q11, Q13 and Q15, the embedding model choice, why vectors are not in sqlite-vec, the measured thresholds, how the UI server differs from D7 as written, and what is still unverified. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- spec/2026-09-13-distill-foundation/README.md | 44 +++++++++++++++++++ .../data-model.md | 3 +- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/spec/2026-09-13-distill-foundation/README.md b/spec/2026-09-13-distill-foundation/README.md index da4c92b..e2406b8 100644 --- a/spec/2026-09-13-distill-foundation/README.md +++ b/spec/2026-09-13-distill-foundation/README.md @@ -234,6 +234,16 @@ agent 判断是不是同一个问题。它本来就是 LLM,判断语义等价 > 按中文二字组和英文单词打分,按 IDF 加权,标题和问题里的命中权重加倍,覆盖不到问题总权重 > 25% 的丢弃。关键词搜索仍用 trigram FTS。实现:`crates/distill-core/src/index/recall.rs`。 +> **2026-09-26 实现备注(P4)。** embedding 用 fastembed(ONNX Runtime 静态链接进 `distill`)加 +> 量化版 `paraphrase-multilingual-MiniLM-L12-v2`(384 维,磁盘约 0.25 GB),这个模型专门训练过 +> 同义句匹配,中英文都能用(2026-09-26 讨论确定,放弃了 candle + multilingual-e5-small 和调用本机 +> Ollama)。模型不自动下载,`distill model pull` 显式拉取;没拉取时 recall 保持关键词打分。 +> 向量没有用 `sqlite-vec`:加载扩展要写 `unsafe`,而工作区 `unsafe_code = "forbid"`;改为在索引的 +> `embedding` 表里存 BLOB、内存里算余弦,几千条 note 在毫秒级。表按「模型 + 文本 hash」做主键, +> `reindex` 不清它,重建索引不用重跑模型。融合用 RRF(k = 60)。阈值是实测出来的:12 对中英文问题里, +> 同一问题换说法得 0.40–0.74,不同问题最高 0.54,两段重叠,所以 recall 的下限定在 0.40、交给 agent +> 判断,合并建议的下限定在 0.65、交给用户确认。现状见 `docs/recall.md`。 + 照搬 reflect-open 的 `retrieve()`:一个函数提供 keyword / semantic / hybrid 三种模式,hybrid 用 RRF 融合,低于相似度阈值的丢弃。embedding 不可用时自动退回 FTS。 @@ -428,6 +438,20 @@ v1 的交付物只有一个 `distill` 可执行文件,`distill ui` 在本机 上面的做法不需要 sudo 也能做到。也不监听 80 端口:本机实测非 root 可以绑定 `0.0.0.0:80`, 但那会把服务暴露给局域网;绑定 `127.0.0.1:80` 需要 root。 +> **2026-09-26 实现备注(P3)。** 与上文不同的几处: +> +> - 没有单独的锁文件。`/api/health` 返回 `{"app": "distill", ...}`,启动前先问端口,它就是锁;两个 +> 进程同时启动时,绑定失败的一方再问一次健康检查。 +> - 后台启动用独立进程组(Unix `process_group(0)`,Windows `DETACHED_PROCESS`),不 fork 两次; +> `distill mcp` 拉起前检查本机配置 `ui.autostart`(默认开),关掉后不拉起。 +> - 一次性 token 和长期 secret 都放在数据目录 `ui/` 下,所以不管服务是谁拉起的,终端里的 +> `distill ui` 都能给浏览器授权。 +> - 除了 cookie 和 Host,还校验 `Origin`:所有 `*.localhost` 端口都算同一个 site,`SameSite=Strict` +> 挡不住本机其他开发服务器带着 cookie 发 POST。非 GET 请求没有 `Origin` 时,只接受带 secret 的 +> CLI 请求。 +> +> 现状见 `docs/web-ui.md`。 + **localhost 安全必须做对。** 用户打开的任何网页都能向 localhost 发请求,所以: - 只监听回环地址(`127.0.0.1` 和 `::1`); @@ -471,6 +495,9 @@ Tauri 把同一个 Web UI 装进窗口。core 是 Rust crate,可以直接链 | 长列表 | `virtua` | | 命令面板 | `cmdk` | +> **2026-09-26 实现备注(P3)。** 路由用代码定义,不生成 route tree;topic 列表只有几种固定排序, +> 没有引入 TanStack Table。字体用 `@fontsource` 拉丁子集随构建离线提供,中文回落到系统字体。 + 不需要 SSR:页面只在本机打开,首屏是从 localhost 读几百 KB 的静态文件。所以不用 TanStack Start 这类全栈框架,服务端只有 `distill ui` 一个。 @@ -652,5 +679,22 @@ Q9 → D12,Q12 → D6,Q14 → 下面这条: | 后台任务的 LLM 和 embedding 从哪来(原 Q5) | P4 | 本地多语 embedding + 无头 agent(`codex exec` / `claude -p`)做 topic 命名和 tag 整理 | | 更多来源(原 Q8) | 需要时 | ChatGPT / Claude.ai 导出、Cursor、Gemini CLI;只影响 `SourceAdapter` 接口 | +> **2026-09-26 决定(P3、P4 开工时)。** +> +> - **Q11**:按倾向做。Web UI 能增删改 annotation、给 topic 改名、合并 topic、处理冲突副本。保留 +> 冲突中的一份时,其余副本移到本机数据目录 `discarded/<vault-id>/`,不删除。note 正文和 tag +> 仍用编辑器改。 +> - **Q13**:候选全做。统计页有每周 note 数(最近 16 周)、按 tag / 项目的分布、重问最多的 +> topic、重问过却没有 annotation 的 topic;topic 的时间线放在 topic 页。 +> - **Q15**:只做整条 note 的 annotation,`anchor` 继续保留不写。 +> - **Q5**:embedding 见 D3 的 2026-09-26 备注。topic 命名与合并**不跑后台 LLM**:topic 的名字 +> 在 Web UI 手动改,embedding 找出「可能是同一个问题」的 topic 对,在 Web UI 列出来,由用户一键 +> 合并或标记「不是同一个问题」(标记只存在本机索引里)。无头 agent 会消耗用户的 agent 额度、 +> 结果不确定,而命名只影响可读性、不影响提问次数,不值得。tag 整理仍靠 D5 的别名文件,没有自动化。 + P2 用起来 2–4 周后回看一次数据:重问是否真的频繁、匹配是否准。这是对整个产品假设的验证, 结果决定 P3、P4 的优先级。 + +> **2026-09-26 进展。** P3、P4 已实现。P4 退出标准由 `semantic.rs` 覆盖(需先拉取模型);P3 的 +> 授权、note 页、tag 跳转已在本机浏览器走过。未验证:真实会话里点开 agent 给的链接并深链跳回 +> 原会话;Linux、Windows 上 `*.localhost` 的解析。 diff --git a/spec/2026-09-13-distill-foundation/data-model.md b/spec/2026-09-13-distill-foundation/data-model.md index 4586482..b08f48a 100644 --- a/spec/2026-09-13-distill-foundation/data-model.md +++ b/spec/2026-09-13-distill-foundation/data-model.md @@ -48,7 +48,8 @@ note id, topic_id, title, question, conclusion, created_at, source_*, pa topic id, label, merged_into? -- 查询时解析到最终 topic tag / note_tag note_fts FTS5 -embedding vec0 虚表 -- P4 +embedding model, text_hash, vector BLOB -- P4;没有用 vec0,见 README D3 的 2026-09-26 备注 +dismissed_pair topic_a, topic_b -- P4:用户标记「不是同一个问题」的 topic 对 annotation id, note_id, body, anchor?, created_at, updated_at, path conflict id, path -- 同一 frontmatter id 对应多个文件 ``` From 92cbf7b9661552f05ddf93d8ccf0682376ea5791 Mon Sep 17 00:00:00 2001 From: Yanlin <jackey.dou@gmail.com> Date: Sat, 26 Sep 2026 12:58:24 -0700 Subject: [PATCH 6/7] Store and search vectors with sqlite-vec Note and topic vectors now live in vec0 tables with cosine distance, and recall, similar topics and duplicate suggestions are KNN queries instead of in-memory scans, as spec D3 planned. Registering the statically linked extension needs one unsafe call, so the workspace lint goes from forbid to deny with a single audited exception in index/vec.rs. Migration 0002 is rewritten in place; it has not shipped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- Cargo.lock | 10 + Cargo.toml | 4 +- crates/distill-core/CHANGELOG.md | 5 +- crates/distill-core/Cargo.toml | 1 + crates/distill-core/README.md | 4 +- .../migrations/0002_embeddings.sql | 30 +- crates/distill-core/src/embed.rs | 8 +- crates/distill-core/src/error.rs | 3 + crates/distill-core/src/index/mod.rs | 2 + crates/distill-core/src/index/semantic.rs | 365 ++++++++++++------ crates/distill-core/src/index/vec.rs | 34 ++ docs/recall.md | 31 +- spec/2026-09-13-distill-foundation/README.md | 6 +- .../data-model.md | 3 +- 14 files changed, 347 insertions(+), 159 deletions(-) create mode 100644 crates/distill-core/src/index/vec.rs diff --git a/Cargo.lock b/Cargo.lock index cd0756d..7a5e2ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -744,6 +744,7 @@ dependencies = [ "schemars", "serde", "serde_json", + "sqlite-vec", "strsim", "tempfile", "thiserror", @@ -2639,6 +2640,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "sqlite-vec" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0ba424237a9a5db2f6071f193319e2b6a32f7f3961debb2fbbfe67067abce3f" +dependencies = [ + "cc", +] + [[package]] name = "sqlite-wasm-rs" version = "0.5.5" diff --git a/Cargo.toml b/Cargo.toml index a3b4535..ed30a5e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,9 @@ rust-version = "1.95" license = "MIT" [workspace.lints.rust] -unsafe_code = "forbid" +# `deny`, not `forbid`, so the one audited exception can opt in: +# crates/distill-core/src/index/vec.rs registers sqlite-vec with SQLite. +unsafe_code = "deny" [workspace.lints.clippy] unwrap_used = "warn" diff --git a/crates/distill-core/CHANGELOG.md b/crates/distill-core/CHANGELOG.md index bc1e49c..d9a2868 100644 --- a/crates/distill-core/CHANGELOG.md +++ b/crates/distill-core/CHANGELOG.md @@ -26,8 +26,9 @@ - `ui.autostart` setting (default `true`) and `LocalConfig::ui_origin`. - Semantic recall: `Distill::recall` fuses keyword ranking with cosine similarity from a local multilingual embedding model (paraphrase-multilingual-MiniLM-L12-v2, quantized, via - fastembed) when it has been pulled with `Embedder::pull`. Vectors are cached in the index - (migration `0002_embeddings.sql`) and survive `reindex`. + fastembed) when it has been pulled with `Embedder::pull`. Vectors are stored in sqlite-vec + `vec0` tables in the index (migration `0002_embeddings.sql`), searched with KNN queries, + and survive `reindex`. - Duplicate topics: `Distill::duplicate_topics`, `similar_topics` and `Index::dismiss_pair`. - `DoctorReport::embedding_model`. - TypeScript types for every API view type, generated by ts-rs into diff --git a/crates/distill-core/Cargo.toml b/crates/distill-core/Cargo.toml index bcf8478..de31ea6 100644 --- a/crates/distill-core/Cargo.toml +++ b/crates/distill-core/Cargo.toml @@ -16,6 +16,7 @@ rusqlite_migration = "2.6.0" schemars = { version = "1", optional = true } serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" +sqlite-vec = "0.1.9" strsim = "0.11.1" tempfile = "3.27.0" thiserror = "2.0.21" diff --git a/crates/distill-core/README.md b/crates/distill-core/README.md index 215f02d..513c39a 100644 --- a/crates/distill-core/README.md +++ b/crates/distill-core/README.md @@ -51,8 +51,8 @@ are rejected. ## Limits -- Recall scores every note in memory: CJK bigrams and words weighted by IDF, plus cosine - similarity of 384-dimension vectors when the model is installed. It is meant for a personal +- Recall scores every note in memory on CJK bigrams and words weighted by IDF, plus a + sqlite-vec KNN query over 384-dimension vectors when the model is installed. It is meant for a personal vault of thousands of notes. - The embedding model is downloaded only by `Embedder::pull` (about 0.25 GB into `<data dir>/models`). Building this crate downloads ONNX Runtime (fastembed's prebuilt diff --git a/crates/distill-core/migrations/0002_embeddings.sql b/crates/distill-core/migrations/0002_embeddings.sql index b9dffa5..f34be35 100644 --- a/crates/distill-core/migrations/0002_embeddings.sql +++ b/crates/distill-core/migrations/0002_embeddings.sql @@ -1,12 +1,24 @@ --- Note vectors for semantic recall (P4). Keyed by model and by the hash of the embedded --- text, not by file: `distill reindex` clears the file tables but keeps these, so a rebuild --- does not re-run the model. Rows for texts no longer in the vault are pruned when missing --- vectors are computed. -CREATE TABLE embedding ( - model TEXT NOT NULL, - text_hash TEXT NOT NULL, - vector BLOB NOT NULL, - PRIMARY KEY (model, text_hash) +-- Vectors for semantic recall (P4), in sqlite-vec `vec0` tables with cosine distance. +-- +-- note_vec is keyed by `<model>:<hash of the embedded text>`, not by file: `distill +-- reindex` clears the file tables but keeps this one, so a rebuild does not re-run the +-- model. Keys no note uses any more are deleted when missing vectors are computed. +CREATE VIRTUAL TABLE note_vec USING vec0 ( + key TEXT PRIMARY KEY, + vector FLOAT[384] distance_metric=cosine +); + +-- One vector per topic: the normalized mean of its notes' vectors. Rebuilt whenever the +-- set of (topic, note vector) pairs changes; `vec_state.topic_vec` holds the hash of that +-- set. +CREATE VIRTUAL TABLE topic_vec USING vec0 ( + topic_id TEXT PRIMARY KEY, + vector FLOAT[384] distance_metric=cosine +); + +CREATE TABLE vec_state ( + name TEXT PRIMARY KEY, + value TEXT NOT NULL ); -- Topic pairs the user marked "not the same question", so they are not suggested again. diff --git a/crates/distill-core/src/embed.rs b/crates/distill-core/src/embed.rs index bf45185..a7bc59a 100644 --- a/crates/distill-core/src/embed.rs +++ b/crates/distill-core/src/embed.rs @@ -123,11 +123,6 @@ fn normalized(mut v: Vec<f32>) -> Vec<f32> { v } -/// Cosine similarity of two unit-length vectors. -pub fn cosine(a: &[f32], b: &[f32]) -> f32 { - a.iter().zip(b).map(|(x, y)| x * y).sum() -} - /// The mean direction of several unit vectors, itself unit length. pub fn centroid<'a>(vectors: impl IntoIterator<Item = &'a [f32]>) -> Option<Vec<f32>> { let mut sum: Option<Vec<f32>> = None; @@ -165,9 +160,8 @@ mod tests { fn vectors_round_trip_and_compare() { let a = normalized(vec![3.0, 4.0]); assert_eq!(from_blob(&to_blob(&a)), a); - assert!((cosine(&a, &a) - 1.0).abs() < 1e-6); let c = centroid([[1.0, 0.0].as_slice(), [0.0, 1.0].as_slice()]).unwrap_or_default(); assert!((c[0] - c[1]).abs() < 1e-6); - assert!((cosine(&c, &c) - 1.0).abs() < 1e-6); + assert!((c.iter().map(|x| x * x).sum::<f32>() - 1.0).abs() < 1e-6); } } diff --git a/crates/distill-core/src/error.rs b/crates/distill-core/src/error.rs index 8e08e4d..0f592c1 100644 --- a/crates/distill-core/src/error.rs +++ b/crates/distill-core/src/error.rs @@ -100,6 +100,9 @@ pub enum Error { source: fastembed::Error, }, + #[error("could not register the sqlite-vec extension: {reason}")] + VectorExtension { reason: String }, + #[error("I/O error at {path}")] Io { path: PathBuf, diff --git a/crates/distill-core/src/index/mod.rs b/crates/distill-core/src/index/mod.rs index 2977d7b..a141cc1 100644 --- a/crates/distill-core/src/index/mod.rs +++ b/crates/distill-core/src/index/mod.rs @@ -9,6 +9,7 @@ mod query; mod recall; mod scan; mod semantic; +mod vec; use std::path::{Path, PathBuf}; use std::sync::LazyLock; @@ -44,6 +45,7 @@ impl Index { if let Some(dir) = db_path.parent() { std::fs::create_dir_all(dir).at(dir)?; } + vec::register()?; let mut conn = Connection::open(db_path)?; conn.busy_timeout(Duration::from_secs(5))?; conn.pragma_update(None, "journal_mode", "WAL")?; diff --git a/crates/distill-core/src/index/semantic.rs b/crates/distill-core/src/index/semantic.rs index 15d9713..a6c544c 100644 --- a/crates/distill-core/src/index/semantic.rs +++ b/crates/distill-core/src/index/semantic.rs @@ -1,20 +1,19 @@ //! Embedding-backed reads: recall that finds reworded questions keyword matching misses, //! and topic pairs that are probably the same question (merge suggestions, spec P4). //! -//! Vectors live in the index next to everything else and are compared in memory: a -//! personal vault has thousands of notes, which a linear scan over 384-dimension vectors -//! handles in milliseconds. `sqlite-vec` is not used; loading an extension needs `unsafe`, -//! which this workspace forbids. +//! Vectors live in sqlite-vec `vec0` tables in the index (`0002_embeddings.sql`), and every +//! nearest-neighbour search is a `vec0` KNN query with cosine distance. Topic vectors are the +//! mean of their notes' vectors and are rebuilt only when the notes or topics change. use std::collections::{HashMap, HashSet}; -use rusqlite::params; +use rusqlite::{OptionalExtension, params}; use serde::Serialize; use super::Index; use super::browse::TopicRef; use super::query::{RecallResult, Row}; -use crate::embed::{Embedder, MODEL_KEY, centroid, cosine, from_blob, note_text, to_blob}; +use crate::embed::{Embedder, MODEL_KEY, centroid, from_blob, note_text, to_blob}; use crate::error::Result; use crate::fsutil::content_hash; @@ -26,8 +25,10 @@ const RECALL_MIN: f32 = 0.4; /// Topic pairs at or above this are suggested for merging. Above every different-question /// pair measured; the user confirms each merge or dismisses the pair. pub const DUPLICATE_MIN: f32 = 0.65; -/// How many semantic hits take part in fusion. +/// How many nearest notes take part in fusion. const SEMANTIC_CANDIDATES: usize = 30; +/// Nearest topics checked per topic when looking for duplicates. +const DUPLICATE_NEIGHBOURS: usize = 5; #[derive(Debug, Clone, Serialize, ts_rs::TS)] #[ts(export)] @@ -49,40 +50,40 @@ pub struct SimilarTopic { } impl Index { - /// Computes vectors for notes that have none under the current model, and drops - /// vectors no note uses any more. Returns how many notes were embedded. + /// Computes vectors for notes that have none under the current model, drops vectors no + /// note uses any more, and refreshes topic vectors when anything changed. Returns how + /// many notes were embedded. pub fn embed_missing(&self, embedder: &Embedder) -> Result<usize> { let rows = self.rows()?; let known: HashSet<String> = { - let mut stmt = self - .conn - .prepare("SELECT text_hash FROM embedding WHERE model = ?1")?; - let hashes = stmt.query_map([MODEL_KEY], |r| r.get(0))?; - hashes.collect::<rusqlite::Result<_>>()? + let mut stmt = self.conn.prepare("SELECT key FROM note_vec")?; + let keys = stmt.query_map([], |r| r.get(0))?; + keys.collect::<rusqlite::Result<_>>()? }; - let mut wanted: HashMap<String, String> = HashMap::new(); - for row in &rows { - let text = note_text(&row.title, &row.question); - wanted.insert(content_hash(text.as_bytes()), text); - } + let wanted: HashMap<String, String> = rows + .iter() + .map(|r| (note_key(r), note_text(&r.title, &r.question))) + .collect(); let missing: Vec<(&String, &String)> = - wanted.iter().filter(|(h, _)| !known.contains(*h)).collect(); - if !missing.is_empty() { + wanted.iter().filter(|(k, _)| !known.contains(*k)).collect(); + let vectors = if missing.is_empty() { + Vec::new() + } else { let texts: Vec<String> = missing.iter().map(|(_, t)| (*t).clone()).collect(); - let vectors = embedder.embed(&texts)?; - for ((hash, _), vector) in missing.iter().zip(vectors) { - self.conn.execute( - "INSERT OR REPLACE INTO embedding (model, text_hash, vector) VALUES (?1, ?2, ?3)", - params![MODEL_KEY, hash, to_blob(&vector)], - )?; - } - } - for stale in known.iter().filter(|h| !wanted.contains_key(*h)) { - self.conn.execute( - "DELETE FROM embedding WHERE model = ?1 AND text_hash = ?2", - params![MODEL_KEY, stale], + embedder.embed(&texts)? + }; + let tx = self.conn.unchecked_transaction()?; + for ((key, _), vector) in missing.iter().zip(&vectors) { + tx.execute( + "INSERT INTO note_vec (key, vector) VALUES (?1, ?2)", + params![key, to_blob(vector)], )?; } + for stale in known.iter().filter(|k| !wanted.contains_key(*k)) { + tx.execute("DELETE FROM note_vec WHERE key = ?1", [stale])?; + } + tx.commit()?; + self.refresh_topic_vectors(&rows)?; Ok(missing.len()) } @@ -94,18 +95,27 @@ impl Index { embedder: &Embedder, ) -> Result<RecallResult> { self.embed_missing(embedder)?; - let query = embedder.embed_one(question)?; - let vectors = self.note_vectors()?; - let mut scored: Vec<(f32, &String)> = vectors + let query = to_blob(&embedder.embed_one(question)?); + let mut stmt = self.conn.prepare( + "SELECT key, distance FROM note_vec WHERE vector MATCH ?1 AND k = ?2 + ORDER BY distance", + )?; + let hits = stmt.query_map(params![query, SEMANTIC_CANDIDATES as i64], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, f64>(1)?)) + })?; + let mut keys: Vec<String> = Vec::new(); + for hit in hits { + let (key, distance) = hit?; + if similarity(distance) >= RECALL_MIN { + keys.push(key); + } + } + // Several notes can share one key when their title and question are identical. + let rows = self.rows()?; + let semantic: Vec<String> = keys .iter() - .map(|(id, v)| (cosine(&query, v), id)) - .filter(|(s, _)| *s >= RECALL_MIN) - .collect(); - scored.sort_by(|a, b| b.0.total_cmp(&a.0).then(a.1.cmp(b.1))); - let semantic: Vec<String> = scored - .into_iter() - .take(SEMANTIC_CANDIDATES) - .map(|(_, id)| id.clone()) + .flat_map(|key| rows.iter().filter(move |r| note_key(r) == *key)) + .map(|r| r.id.clone()) .collect(); self.recall_with(question, limit, Some(&semantic)) } @@ -114,27 +124,35 @@ impl Index { /// dismissed are left out. pub fn duplicate_topics(&self, embedder: &Embedder) -> Result<Vec<TopicPair>> { self.embed_missing(embedder)?; - let topics = self.topic_vectors()?; + let topics = self.topic_info()?; let dismissed = self.dismissed()?; + let mut seen: HashSet<(String, String)> = HashSet::new(); let mut pairs = Vec::new(); - for (i, a) in topics.iter().enumerate() { - for b in &topics[i + 1..] { - let similarity = cosine(&a.vector, &b.vector); - if similarity < DUPLICATE_MIN || dismissed.contains(&ordered(&a.id, &b.id)) { + for id in topics.keys() { + for (other, similarity) in self.nearest_topics(id, DUPLICATE_NEIGHBOURS)? { + let key = ordered(id, &other); + if similarity < DUPLICATE_MIN || dismissed.contains(&key) || !seen.insert(key) { continue; } + let (Some(a), Some(b)) = (topics.get(id), topics.get(&other)) else { + continue; + }; // The more-asked topic is `b`, the natural merge target. let (a, b) = if a.count > b.count { (b, a) } else { (a, b) }; pairs.push(TopicPair { - a: a.topic_ref(), - b: b.topic_ref(), + a: a.topic.clone(), + b: b.topic.clone(), similarity, a_count: a.count, b_count: b.count, }); } } - pairs.sort_by(|x, y| y.similarity.total_cmp(&x.similarity)); + pairs.sort_by(|x, y| { + y.similarity + .total_cmp(&x.similarity) + .then(x.a.topic_id.cmp(&y.a.topic_id)) + }); Ok(pairs) } @@ -147,23 +165,20 @@ impl Index { ) -> Result<Vec<SimilarTopic>> { self.embed_missing(embedder)?; let topic_id = self.resolve_topic(topic_id)?; - let topics = self.topic_vectors()?; - let Some(me) = topics.iter().find(|t| t.id == topic_id) else { - return Ok(Vec::new()); - }; - let mut out: Vec<SimilarTopic> = topics - .iter() - .filter(|t| t.id != topic_id) - .map(|t| SimilarTopic { - similarity: cosine(&me.vector, &t.vector), - ask_count: t.count, - topic: t.topic_ref(), + let topics = self.topic_info()?; + Ok(self + .nearest_topics(&topic_id, limit)? + .into_iter() + .filter(|(_, similarity)| *similarity >= RECALL_MIN) + .filter_map(|(id, similarity)| { + let info = topics.get(&id)?; + Some(SimilarTopic { + topic: info.topic.clone(), + similarity, + ask_count: info.count, + }) }) - .filter(|s| s.similarity >= RECALL_MIN) - .collect(); - out.sort_by(|x, y| y.similarity.total_cmp(&x.similarity)); - out.truncate(limit); - Ok(out) + .collect()) } /// Records that two topics are not the same question. @@ -176,82 +191,138 @@ impl Index { Ok(()) } - fn dismissed(&self) -> Result<HashSet<(String, String)>> { - let mut stmt = self + /// The `limit` topics nearest to `topic_id` by a `vec0` KNN query, with their cosine + /// similarity. Empty when the topic has no vector. + fn nearest_topics(&self, topic_id: &str, limit: usize) -> Result<Vec<(String, f32)>> { + let vector: Option<Vec<u8>> = self .conn - .prepare("SELECT topic_a, topic_b FROM dismissed_pair")?; - let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?; - Ok(rows.collect::<rusqlite::Result<_>>()?) + .query_row( + "SELECT vector FROM topic_vec WHERE topic_id = ?1", + [topic_id], + |r| r.get(0), + ) + .optional()?; + let Some(vector) = vector else { + return Ok(Vec::new()); + }; + let mut stmt = self.conn.prepare( + "SELECT topic_id, distance FROM topic_vec WHERE vector MATCH ?1 AND k = ?2 + ORDER BY distance", + )?; + let hits = stmt.query_map(params![vector, (limit + 1) as i64], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, f64>(1)?)) + })?; + let mut out = Vec::new(); + for hit in hits { + let (id, distance) = hit?; + if id != topic_id { + out.push((id, similarity(distance))); + } + } + out.truncate(limit); + Ok(out) } - /// Note id to vector, for every note that has one. - fn note_vectors(&self) -> Result<HashMap<String, Vec<f32>>> { - let by_hash = self.vectors_by_hash()?; - Ok(self - .rows()? - .into_iter() - .filter_map(|r| { - let hash = content_hash(note_text(&r.title, &r.question).as_bytes()); - by_hash.get(&hash).map(|v| (r.id, v.clone())) - }) - .collect()) + /// Rewrites `topic_vec` when the topics or their notes' vectors changed since the last + /// time. The check is one hash over every (topic, note key) pair. + fn refresh_topic_vectors(&self, rows: &[Row]) -> Result<()> { + let mut members: Vec<(&str, String)> = rows + .iter() + .map(|r| (r.topic_id.as_str(), note_key(r))) + .collect(); + members.sort(); + let signature = content_hash( + members + .iter() + .map(|(t, k)| format!("{t} {k}\n")) + .collect::<String>() + .as_bytes(), + ); + let stored: Option<String> = self + .conn + .query_row( + "SELECT value FROM vec_state WHERE name = 'topic_vec'", + [], + |r| r.get(0), + ) + .optional()?; + if stored.as_deref() == Some(signature.as_str()) { + return Ok(()); + } + let vectors: HashMap<String, Vec<f32>> = { + let mut stmt = self.conn.prepare("SELECT key, vector FROM note_vec")?; + let rows = stmt.query_map([], |r| { + Ok((r.get::<_, String>(0)?, from_blob(&r.get::<_, Vec<u8>>(1)?))) + })?; + rows.collect::<rusqlite::Result<_>>()? + }; + let mut by_topic: HashMap<&str, Vec<&[f32]>> = HashMap::new(); + for (topic, key) in &members { + if let Some(v) = vectors.get(key) { + by_topic.entry(topic).or_default().push(v); + } + } + let tx = self.conn.unchecked_transaction()?; + tx.execute("DELETE FROM topic_vec", [])?; + for (topic, vs) in by_topic { + if let Some(mean) = centroid(vs) { + tx.execute( + "INSERT INTO topic_vec (topic_id, vector) VALUES (?1, ?2)", + params![topic, to_blob(&mean)], + )?; + } + } + tx.execute( + "INSERT OR REPLACE INTO vec_state (name, value) VALUES ('topic_vec', ?1)", + [signature], + )?; + tx.commit()?; + Ok(()) } - fn vectors_by_hash(&self) -> Result<HashMap<String, Vec<f32>>> { + fn dismissed(&self) -> Result<HashSet<(String, String)>> { let mut stmt = self .conn - .prepare("SELECT text_hash, vector FROM embedding WHERE model = ?1")?; - let rows = stmt.query_map([MODEL_KEY], |r| { - Ok((r.get::<_, String>(0)?, from_blob(&r.get::<_, Vec<u8>>(1)?))) - })?; + .prepare("SELECT topic_a, topic_b FROM dismissed_pair")?; + let rows = stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?; Ok(rows.collect::<rusqlite::Result<_>>()?) } - fn topic_vectors(&self) -> Result<Vec<TopicVector>> { - let by_hash = self.vectors_by_hash()?; + /// Label and note count of every topic that has notes. + fn topic_info(&self) -> Result<HashMap<String, TopicInfo>> { let labels = self.topic_labels()?; - let mut notes: HashMap<String, Vec<&Row>> = HashMap::new(); - let rows = self.rows()?; - for row in &rows { - notes.entry(row.topic_id.clone()).or_default().push(row); - } - let mut out: Vec<TopicVector> = notes - .into_iter() - .filter_map(|(id, rows)| { - let vectors: Vec<&Vec<f32>> = rows - .iter() - .filter_map(|r| { - by_hash.get(&content_hash(note_text(&r.title, &r.question).as_bytes())) - }) - .collect(); - let vector = centroid(vectors.iter().map(|v| v.as_slice()))?; - Some(TopicVector { - label: labels.get(&id).cloned().unwrap_or_default(), - count: rows.len(), - id, - vector, + let mut out: HashMap<String, TopicInfo> = HashMap::new(); + for row in self.rows()? { + out.entry(row.topic_id.clone()) + .or_insert_with(|| TopicInfo { + topic: TopicRef { + label: labels.get(&row.topic_id).cloned().unwrap_or_default(), + topic_id: row.topic_id.clone(), + }, + count: 0, }) - }) - .collect(); - out.sort_by(|a, b| a.id.cmp(&b.id)); + .count += 1; + } Ok(out) } } -struct TopicVector { - id: String, - label: String, +struct TopicInfo { + topic: TopicRef, count: usize, - vector: Vec<f32>, } -impl TopicVector { - fn topic_ref(&self) -> TopicRef { - TopicRef { - topic_id: self.id.clone(), - label: self.label.clone(), - } - } +/// `vec0` cosine distance is `1 - cosine similarity`. +fn similarity(distance: f64) -> f32 { + (1.0 - distance) as f32 +} + +/// What a note's vector is stored under: the model and the hash of the embedded text. +fn note_key(row: &Row) -> String { + format!( + "{MODEL_KEY}:{}", + content_hash(note_text(&row.title, &row.question).as_bytes()) + ) } fn ordered(a: &str, b: &str) -> (String, String) { @@ -261,3 +332,47 @@ fn ordered(a: &str, b: &str) -> (String, String) { (b.to_string(), a.to_string()) } } + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::*; + + fn unit(v: &[f32]) -> Vec<f32> { + let mut v = v.to_vec(); + v.resize(384, 0.0); + let norm = v.iter().map(|x| x * x).sum::<f32>().sqrt(); + v.iter().map(|x| x / norm).collect() + } + + #[test] + fn nearest_topics_is_a_vec0_knn_query() { + let dir = tempfile::tempdir().unwrap(); + let index = Index::open(&dir.path().join("index.db"), dir.path()).unwrap(); + let version: String = index + .conn + .query_row("SELECT vec_version()", [], |r| r.get(0)) + .unwrap(); + assert!(version.starts_with('v'), "{version}"); + for (id, v) in [ + ("A", unit(&[1.0, 0.0])), + ("B", unit(&[0.9, 0.1])), + ("C", unit(&[0.0, 1.0])), + ] { + index + .conn + .execute( + "INSERT INTO topic_vec (topic_id, vector) VALUES (?1, ?2)", + params![id, to_blob(&v)], + ) + .unwrap(); + } + let near = index.nearest_topics("A", 2).unwrap(); + assert_eq!(near[0].0, "B"); + assert!(near[0].1 > 0.99, "{near:?}"); + assert_eq!(near[1].0, "C"); + assert!(near[1].1.abs() < 1e-3, "{near:?}"); + assert!(index.nearest_topics("missing", 2).unwrap().is_empty()); + } +} diff --git a/crates/distill-core/src/index/vec.rs b/crates/distill-core/src/index/vec.rs new file mode 100644 index 0000000..9df4d79 --- /dev/null +++ b/crates/distill-core/src/index/vec.rs @@ -0,0 +1,34 @@ +//! Registers the statically linked sqlite-vec extension with SQLite, so every connection +//! opened afterwards has the `vec0` virtual table and the `vec_*` functions (spec D3). +//! +//! This is the only `unsafe` code in the workspace; the workspace lint denies it everywhere +//! else. +#![allow(unsafe_code)] + +use std::sync::OnceLock; + +use rusqlite::auto_extension::{RawAutoExtension, register_auto_extension}; + +use crate::error::{Error, Result}; + +/// Registers once per process, before the first `Connection::open`. +pub(super) fn register() -> Result<()> { + static REGISTERED: OnceLock<std::result::Result<(), String>> = OnceLock::new(); + REGISTERED + .get_or_init(|| { + // SAFETY: `sqlite3_vec_init` is sqlite-vec's C entry point, compiled with + // SQLITE_CORE against the SQLite that rusqlite bundles. Its real signature is + // SQLite's extension-init signature, which is what `RawAutoExtension` describes; + // the crate only declares it as `fn()`. The init routine opens no databases and + // does not touch the auto-extension list, which is what + // `register_auto_extension` requires. + unsafe { + let init = std::mem::transmute::<unsafe extern "C" fn(), RawAutoExtension>( + sqlite_vec::sqlite3_vec_init, + ); + register_auto_extension(init).map_err(|e| e.to_string()) + } + }) + .clone() + .map_err(|reason| Error::VectorExtension { reason }) +} diff --git a/docs/recall.md b/docs/recall.md index 3c206c6..7dff35c 100644 --- a/docs/recall.md +++ b/docs/recall.md @@ -9,7 +9,7 @@ candidate topics; the agent decides whether one is the same question. | Ranking | Always on | How | Code | |---|---|---|---| | Keyword | Yes | Every note scored on CJK bigrams and words, IDF-weighted, title and question counted double; notes covering less than 25% of the question's weight are dropped | `crates/distill-core/src/index/recall.rs` | -| Semantic | After `distill model pull` | Cosine similarity between the question and each note's `title + question` vector; notes below 0.40 are dropped, the top 30 kept | `crates/distill-core/src/index/semantic.rs` | +| Semantic | After `distill model pull` | A sqlite-vec KNN query (cosine distance) for the 30 notes whose `title + question` vectors are nearest the question's; notes below 0.40 similarity are dropped | `crates/distill-core/src/index/semantic.rs` | With the model installed the two lists are merged by reciprocal rank fusion (`1 / (60 + rank)` summed per note), so a note either ranking finds is a candidate and one @@ -36,17 +36,30 @@ keeps it; the MCP server and web server reuse it across calls. ## Vectors -Vectors live in the index table `embedding`, keyed by model and by the BLAKE3 hash of the -embedded text. Notes that lack a vector are embedded on the next recall or duplicate check; -vectors whose text no longer appears in the vault are deleted then. `distill reindex` leaves -the table alone, so a rebuild does not re-run the model. They are compared in memory, not -with `sqlite-vec`. +Vectors live in two sqlite-vec `vec0` tables in the index, both with cosine distance +(`crates/distill-core/migrations/0002_embeddings.sql`): + +| Table | Key | Holds | +|---|---|---| +| `note_vec` | `<model>:<BLAKE3 of the embedded text>` | One vector per distinct note text | +| `topic_vec` | topic id (after merges) | The normalized mean of the topic's note vectors | + +Notes that lack a vector are embedded on the next recall or duplicate check; keys no note uses +any more are deleted then. `topic_vec` is rewritten only when the set of (topic, note key) +pairs changes, which a hash in `vec_state` detects. `distill reindex` leaves these tables +alone, so a rebuild does not re-run the model. + +sqlite-vec is compiled into `distill` (the `sqlite-vec` crate) and registered with SQLite as +an auto-extension before the index opens (`crates/distill-core/src/index/vec.rs`). That +registration is the workspace's only `unsafe` code; the lint is `deny`, and that module opts +out. ## Duplicate topics -A topic's vector is the normalized mean of its notes' vectors. Topic pairs at 0.65 or above -are offered as probably the same question: on the web UI's Topics page ("可能重复"), as a -banner on the home page, and as "相似的 topic" on a topic page (0.40 and above, top 5). +Each topic's five nearest topics come from a KNN query on `topic_vec`. Pairs at 0.65 +similarity or above are offered as probably the same question: on the web UI's Topics page +("可能重复"), as a banner on the home page, and as "相似的 topic" on a topic page (0.40 and +above, top 5). Merging writes `merged_into` as described in [vault-format.md](vault-format.md#topic). "不是同一个问题" records the pair in the index table `dismissed_pair`, on this device only. diff --git a/spec/2026-09-13-distill-foundation/README.md b/spec/2026-09-13-distill-foundation/README.md index e2406b8..0dd9825 100644 --- a/spec/2026-09-13-distill-foundation/README.md +++ b/spec/2026-09-13-distill-foundation/README.md @@ -238,9 +238,9 @@ agent 判断是不是同一个问题。它本来就是 LLM,判断语义等价 > 量化版 `paraphrase-multilingual-MiniLM-L12-v2`(384 维,磁盘约 0.25 GB),这个模型专门训练过 > 同义句匹配,中英文都能用(2026-09-26 讨论确定,放弃了 candle + multilingual-e5-small 和调用本机 > Ollama)。模型不自动下载,`distill model pull` 显式拉取;没拉取时 recall 保持关键词打分。 -> 向量没有用 `sqlite-vec`:加载扩展要写 `unsafe`,而工作区 `unsafe_code = "forbid"`;改为在索引的 -> `embedding` 表里存 BLOB、内存里算余弦,几千条 note 在毫秒级。表按「模型 + 文本 hash」做主键, -> `reindex` 不清它,重建索引不用重跑模型。融合用 RRF(k = 60)。阈值是实测出来的:12 对中英文问题里, +> 向量按原计划放 `sqlite-vec` 的 `vec0` 表、用 KNN 查询。注册扩展要一段 `unsafe`,工作区的 +> `unsafe_code` 从 `forbid` 改为 `deny`,只有 `index/vec.rs` 例外。note 向量按「模型 + 文本 hash」 +> 做主键,`reindex` 不清它;topic 向量是 note 向量的均值,另存一张 `vec0` 表。融合用 RRF(k = 60)。阈值是实测出来的:12 对中英文问题里, > 同一问题换说法得 0.40–0.74,不同问题最高 0.54,两段重叠,所以 recall 的下限定在 0.40、交给 agent > 判断,合并建议的下限定在 0.65、交给用户确认。现状见 `docs/recall.md`。 diff --git a/spec/2026-09-13-distill-foundation/data-model.md b/spec/2026-09-13-distill-foundation/data-model.md index b08f48a..3466916 100644 --- a/spec/2026-09-13-distill-foundation/data-model.md +++ b/spec/2026-09-13-distill-foundation/data-model.md @@ -48,7 +48,8 @@ note id, topic_id, title, question, conclusion, created_at, source_*, pa topic id, label, merged_into? -- 查询时解析到最终 topic tag / note_tag note_fts FTS5 -embedding model, text_hash, vector BLOB -- P4;没有用 vec0,见 README D3 的 2026-09-26 备注 +note_vec vec0: key(模型:文本 hash), vector -- P4,见 README D3 的 2026-09-26 备注 +topic_vec vec0: topic_id, vector -- P4:topic 内 note 向量的均值 dismissed_pair topic_a, topic_b -- P4:用户标记「不是同一个问题」的 topic 对 annotation id, note_id, body, anchor?, created_at, updated_at, path conflict id, path -- 同一 frontmatter id 对应多个文件 From 96fbf77f8e78676c190f176d73abdcefa9d15dda Mon Sep 17 00:00:00 2001 From: Yanlin <jackey.dou@gmail.com> Date: Sat, 26 Sep 2026 13:37:49 -0700 Subject: [PATCH 7/7] Reorganize the web UI into Distill and Review tabs The sidebar now has two tabs with every tag listed below them. Distill is a timeline of notes grouped by day: the question and the first two lines of the conclusion, expanding in place to the whole note, filtered by the chosen tag. Review is a dashboard: week-over-week notes, repeat rate, annotation coverage, active days, a 26-week activity calendar, notes per week, most-asked and unannotated topics, duplicate suggestions, and notes by tag, project and agent. The core gains a cursor-paged timeline and per-day and per-agent counts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/web/CHANGELOG.md | 6 + apps/web/README.md | 16 +- apps/web/package.json | 3 +- apps/web/src/api/client.ts | 4 + apps/web/src/api/generated/AgentCount.ts | 4 + apps/web/src/api/generated/DayCount.ts | 3 + apps/web/src/api/generated/Stats.ts | 12 +- apps/web/src/api/generated/TimelineItem.ts | 16 ++ apps/web/src/api/generated/TimelinePage.ts | 12 + apps/web/src/api/generated/TimelineQuery.ts | 7 + apps/web/src/api/queries.ts | 15 +- apps/web/src/components/CommandPalette.tsx | 2 +- apps/web/src/components/Layout.tsx | 187 +++++++++++----- apps/web/src/components/NoteList.tsx | 50 ----- apps/web/src/components/TopicRows.tsx | 13 +- apps/web/src/components/charts.tsx | 136 +++++++++-- apps/web/src/components/ui.tsx | 6 +- apps/web/src/lib/format.test.ts | 21 +- apps/web/src/lib/format.ts | 48 ++++ apps/web/src/lib/weeks.test.ts | 12 +- apps/web/src/lib/weeks.ts | 16 ++ apps/web/src/pages/DistillPage.tsx | 235 ++++++++++++++++++++ apps/web/src/pages/HomePage.tsx | 98 -------- apps/web/src/pages/NotesPage.tsx | 48 ---- apps/web/src/pages/ReviewPage.tsx | 203 +++++++++++++++++ apps/web/src/pages/StatsPage.tsx | 73 ------ apps/web/src/pages/TagsPage.tsx | 48 ---- apps/web/src/pages/TopicsPage.tsx | 75 ------- apps/web/src/router.tsx | 30 +-- apps/web/src/styles.css | 3 + crates/distill-cli/CHANGELOG.md | 2 +- crates/distill-cli/src/ui/api.rs | 27 ++- crates/distill-core/CHANGELOG.md | 2 + crates/distill-core/README.md | 1 + crates/distill-core/src/index/browse.rs | 94 +++++++- crates/distill-core/src/index/mod.rs | 6 +- crates/distill-core/src/index/query.rs | 44 ++++ crates/distill-core/tests/browse.rs | 46 ++++ docs/web-ui.md | 3 +- pnpm-lock.yaml | 34 --- spec/2026-09-13-distill-foundation/flows.md | 5 + 41 files changed, 1111 insertions(+), 555 deletions(-) create mode 100644 apps/web/src/api/generated/AgentCount.ts create mode 100644 apps/web/src/api/generated/DayCount.ts create mode 100644 apps/web/src/api/generated/TimelineItem.ts create mode 100644 apps/web/src/api/generated/TimelinePage.ts create mode 100644 apps/web/src/api/generated/TimelineQuery.ts delete mode 100644 apps/web/src/components/NoteList.tsx create mode 100644 apps/web/src/pages/DistillPage.tsx delete mode 100644 apps/web/src/pages/HomePage.tsx delete mode 100644 apps/web/src/pages/NotesPage.tsx create mode 100644 apps/web/src/pages/ReviewPage.tsx delete mode 100644 apps/web/src/pages/StatsPage.tsx delete mode 100644 apps/web/src/pages/TagsPage.tsx delete mode 100644 apps/web/src/pages/TopicsPage.tsx create mode 100644 crates/distill-core/tests/browse.rs diff --git a/apps/web/CHANGELOG.md b/apps/web/CHANGELOG.md index 196664d..2ee6571 100644 --- a/apps/web/CHANGELOG.md +++ b/apps/web/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +### Changed +- The sidebar holds two tabs, Distill and Review, with every tag listed below them. Distill + is a timeline of notes (question plus the first two lines of the conclusion, expanding in + place to the whole note); Review is a dashboard of activity metrics. The separate home, + notes, topics, tags and stats pages are gone; their content moved into these two. + ### Added - Web UI served by `distill ui`: home, notes search, note page, topics with timelines, tags, stats and a problems page. See `README.md`. diff --git a/apps/web/README.md b/apps/web/README.md index 9aeacca..51f2537 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -7,7 +7,7 @@ or index; everything goes through the JSON API in [docs/web-ui.md](../../docs/we ## Stack React 19 with the React Compiler, TanStack Router (code-based routes in `src/router.tsx`) and -TanStack Query, Tailwind v4, `virtua` for long lists, `cmdk` for the ⌘K palette, +TanStack Query, Tailwind v4, `cmdk` for the ⌘K palette, `react-markdown` with GFM for note bodies. Fonts come from `@fontsource` packages (IBM Plex Serif, iA Writer Quattro, Lilex; Latin subsets) and ship inside the bundle, so the UI works offline. Chinese text falls back to the system's CJK fonts. @@ -21,7 +21,7 @@ offline. Chinese text falls back to the system's CJK fonts. | `src/api/queries.ts` | Query keys, the query client, the SSE subscription that refetches on change | | `src/api/generated/` | TypeScript API types generated from Rust by ts-rs. Never edit by hand | | `src/pages/` | One component per route | -| `src/components/` | Shared pieces: layout, note list, annotations, charts, command palette | +| `src/components/` | Shared pieces: layout and sidebar, annotations, charts, command palette | | `src/lib/` | Formatting and ISO-week helpers, with tests | | `src/styles.css` | Design tokens (semantic color roles, fonts) and note prose styles | @@ -30,15 +30,15 @@ Design direction and token rules: the ## Pages +The sidebar has two tabs, Distill and Review, and below them every tag with its note count. +Choosing a tag filters the Distill timeline. + | Route | Shows | |---|---| -| `/` | Most-asked topics, repeated topics without an annotation, recent notes, a banner when files need attention | -| `/notes?q=` | Keyword search, or recent notes | -| `/notes/$noteId` | The note, its topic and ask count, tags, annotations (add, edit, delete), how to reopen the session | -| `/topics?view=asked\|recent\|undigested\|duplicates` | Every topic, or topic pairs that are probably one question | +| `/?tag=` | **Distill**: every note newest first, grouped by day. Each entry shows the question and the first two lines of the conclusion; activating it loads the whole note in place. Loads 30 at a time as you scroll | +| `/review` | **Review**: this week against last week, repeat rate, annotation coverage of repeated topics, active days; a 26-week activity calendar; notes per week; most-asked and unannotated topics; duplicate topic suggestions; notes by tag, project and agent | +| `/notes/$noteId` | The note, its topic and ask count, tags, annotations (add, edit, delete), how to reopen the session. Agent links point here | | `/topics/$topicId` | Timeline of the topic's notes; rename; similar topics; merge into another topic | -| `/tags`, `/tags/$tag` | Tags by note count; notes with a tag | -| `/stats` | Counts, notes per ISO week (16 weeks), most-asked and unannotated topics, tags, projects | | `/problems` | Sync conflicts (keep one copy) and unreadable files | ## Scripts diff --git a/apps/web/package.json b/apps/web/package.json index 67dbf71..3787ea2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,8 +22,7 @@ "react": "19.3.0", "react-dom": "19.3.0", "react-markdown": "10.1.0", - "remark-gfm": "4.0.1", - "virtua": "0.52.7" + "remark-gfm": "4.0.1" }, "devDependencies": { "@babel/core": "^8.0.6", diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 57ad8cd..7068bce 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -12,6 +12,8 @@ import type { Session } from "./generated/Session"; import type { SimilarTopic } from "./generated/SimilarTopic"; import type { Stats } from "./generated/Stats"; import type { TagCount } from "./generated/TagCount"; +import type { TimelinePage } from "./generated/TimelinePage"; +import type { TimelineQuery } from "./generated/TimelineQuery"; import type { TopicCount } from "./generated/TopicCount"; import type { TopicDetail } from "./generated/TopicDetail"; import type { TopicMerge } from "./generated/TopicMerge"; @@ -32,6 +34,8 @@ export function queryString(params: object): string { export const api = { session: () => bridge().request<Session>("GET", "/session"), + timeline: (q: TimelineQuery) => + bridge().request<TimelinePage>("GET", `/timeline${queryString(q)}`), notes: (q: NotesQuery) => bridge().request<NoteHit[]>("GET", `/notes${queryString(q)}`), note: (id: string) => bridge().request<NoteDetail>("GET", `/notes/${enc(id)}`), addAnnotation: (noteId: string, body: AnnotationInput) => diff --git a/apps/web/src/api/generated/AgentCount.ts b/apps/web/src/api/generated/AgentCount.ts new file mode 100644 index 0000000..84943bb --- /dev/null +++ b/apps/web/src/api/generated/AgentCount.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Agent } from "./Agent"; + +export type AgentCount = { agent: Agent, notes: number, }; diff --git a/apps/web/src/api/generated/DayCount.ts b/apps/web/src/api/generated/DayCount.ts new file mode 100644 index 0000000..2d77374 --- /dev/null +++ b/apps/web/src/api/generated/DayCount.ts @@ -0,0 +1,3 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type DayCount = { day: string, notes: number, }; diff --git a/apps/web/src/api/generated/Stats.ts b/apps/web/src/api/generated/Stats.ts index 0897012..b98ee36 100644 --- a/apps/web/src/api/generated/Stats.ts +++ b/apps/web/src/api/generated/Stats.ts @@ -1,7 +1,17 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AgentCount } from "./AgentCount"; +import type { DayCount } from "./DayCount"; import type { ProjectCount } from "./ProjectCount"; import type { TagCount } from "./TagCount"; import type { TopicCount } from "./TopicCount"; import type { WeekCount } from "./WeekCount"; -export type Stats = { notes: number, topics: number, repeated_topics: number, annotations: number, top_topics: Array<TopicCount>, tags: Array<TagCount>, by_week: Array<WeekCount>, by_project: Array<ProjectCount>, conflicts: number, invalid_files: number, }; +export type Stats = { notes: number, topics: number, repeated_topics: number, annotations: number, top_topics: Array<TopicCount>, tags: Array<TagCount>, by_week: Array<WeekCount>, +/** + * Notes per local calendar day (`2026-09-13`), days without notes left out. + */ +by_day: Array<DayCount>, by_project: Array<ProjectCount>, by_agent: Array<AgentCount>, +/** + * Topics asked twice or more that have at least one annotation. + */ +annotated_repeated_topics: number, conflicts: number, invalid_files: number, }; diff --git a/apps/web/src/api/generated/TimelineItem.ts b/apps/web/src/api/generated/TimelineItem.ts new file mode 100644 index 0000000..dade7ea --- /dev/null +++ b/apps/web/src/api/generated/TimelineItem.ts @@ -0,0 +1,16 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { Source } from "./Source"; + +/** + * One note on the timeline: the question and the start of the answer, plus where it sits + * in its topic. + */ +export type TimelineItem = { id: string, topic_id: string, topic_label: string, title: string, question: string, conclusion: string, tags: Array<string>, created: string, source: Source, +/** + * This note is the Nth in its topic, oldest first: "the Nth time you asked". + */ +ask_index: number, +/** + * Notes in the topic. + */ +ask_count: number, annotations: number, }; diff --git a/apps/web/src/api/generated/TimelinePage.ts b/apps/web/src/api/generated/TimelinePage.ts new file mode 100644 index 0000000..036e9c8 --- /dev/null +++ b/apps/web/src/api/generated/TimelinePage.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { TimelineItem } from "./TimelineItem"; + +export type TimelinePage = { +/** + * Newest first. + */ +items: Array<TimelineItem>, +/** + * Pass back as `before` for the next page; absent after the last page. + */ +next?: string, }; diff --git a/apps/web/src/api/generated/TimelineQuery.ts b/apps/web/src/api/generated/TimelineQuery.ts new file mode 100644 index 0000000..98880a0 --- /dev/null +++ b/apps/web/src/api/generated/TimelineQuery.ts @@ -0,0 +1,7 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type TimelineQuery = { tag?: string, +/** + * The `next` cursor from the previous page. + */ +before?: string, limit?: number, }; diff --git a/apps/web/src/api/queries.ts b/apps/web/src/api/queries.ts index b1623e2..5840e01 100644 --- a/apps/web/src/api/queries.ts +++ b/apps/web/src/api/queries.ts @@ -1,4 +1,10 @@ -import { QueryClient, queryOptions, useMutation, useQueryClient } from "@tanstack/react-query"; +import { + infiniteQueryOptions, + QueryClient, + queryOptions, + useMutation, + useQueryClient, +} from "@tanstack/react-query"; import { useEffect } from "react"; import { ApiError } from "./bridge"; import { api } from "./client"; @@ -16,6 +22,13 @@ export const queryClient = new QueryClient({ export const q = { session: () => queryOptions({ queryKey: ["session"], queryFn: api.session }), + timeline: (tag: string | undefined) => + infiniteQueryOptions({ + queryKey: ["timeline", tag], + queryFn: ({ pageParam }) => api.timeline({ tag, before: pageParam, limit: 30 }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (page) => page.next, + }), notes: (query: NotesQuery) => queryOptions({ queryKey: ["notes", query], queryFn: () => api.notes(query) }), note: (id: string) => queryOptions({ queryKey: ["note", id], queryFn: () => api.note(id) }), diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index d406c2d..ad04ee4 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -92,7 +92,7 @@ export function CommandPalette({ key={t.tag} value={`tag:${t.tag}`} className={ITEM} - onSelect={() => go(() => navigate({ to: "/tags/$tag", params: { tag: t.tag } }))} + onSelect={() => go(() => navigate({ to: "/", search: { tag: t.tag } }))} > <Hash className="size-4 shrink-0 text-ink-faint" aria-hidden /> {t.tag} diff --git a/apps/web/src/components/Layout.tsx b/apps/web/src/components/Layout.tsx index 55ec13d..907f940 100644 --- a/apps/web/src/components/Layout.tsx +++ b/apps/web/src/components/Layout.tsx @@ -1,12 +1,12 @@ import { useQuery } from "@tanstack/react-query"; -import { Link, Outlet } from "@tanstack/react-router"; -import { Search } from "lucide-react"; -import { type ReactNode, useState } from "react"; +import { Link, Outlet, useRouterState } from "@tanstack/react-router"; +import { Search, TriangleAlert } from "lucide-react"; +import { useState } from "react"; import { ApiError } from "../api/bridge"; import { q, useVaultEvents } from "../api/queries"; import { CommandPalette } from "./CommandPalette"; import { ThemeToggle } from "./ThemeToggle"; -import { ErrorBox, Loading } from "./ui"; +import { cx, ErrorBox, Loading } from "./ui"; export function Layout() { const session = useQuery(q.session()); @@ -17,10 +17,10 @@ export function Layout() { return <AuthRequired />; } return ( - <div className="min-h-screen lg:grid lg:grid-cols-[15rem_minmax(0,1fr)]"> + <div className="min-h-screen lg:grid lg:grid-cols-[16rem_minmax(0,1fr)]"> <Sidebar onSearch={() => setPaletteOpen(true)} /> - <main className="min-w-0 px-4 pt-6 pb-24 sm:px-8 lg:px-12 lg:pt-12"> - <div className="mx-auto max-w-[52rem]"> + <main className="min-w-0 px-4 pt-6 pb-24 sm:px-8 lg:px-12 lg:pt-10"> + <div className="mx-auto max-w-[64rem]"> {session.isPending ? ( <Loading /> ) : session.isError ? ( @@ -35,85 +35,156 @@ export function Layout() { ); } +type Tab = "distill" | "review"; + +/** Review owns the dashboard and the problems page; everything else reads notes. */ +function currentTab(pathname: string): Tab { + return pathname.startsWith("/review") || pathname.startsWith("/problems") ? "review" : "distill"; +} + function Sidebar({ onSearch }: { onSearch: () => void }) { + const location = useRouterState({ select: (s) => s.location }); + const tab = currentTab(location.pathname); + const activeTag = + location.pathname === "/" && typeof location.search.tag === "string" + ? location.search.tag + : undefined; const problems = useQuery(q.problems()); const problemCount = (problems.data?.conflicts.length ?? 0) + (problems.data?.invalid_files.length ?? 0); + return ( - <aside className="border-b border-line bg-panel lg:sticky lg:top-0 lg:h-screen lg:border-r lg:border-b-0"> - <div className="flex items-center justify-between gap-2 px-4 py-3 lg:px-5 lg:pt-6 lg:pb-5"> + <aside className="flex flex-col border-b border-line bg-panel lg:sticky lg:top-0 lg:h-screen lg:border-r lg:border-b-0"> + <div className="flex items-center justify-between gap-2 px-4 pt-3 lg:px-5 lg:pt-6"> <Link to="/" className="flex items-center gap-2 font-display text-xl text-ink"> <span className="inline-block size-3 bg-brand" aria-hidden /> Distill </Link> <div className="flex items-center gap-2 lg:hidden"> <ThemeToggle /> - <button - type="button" - onClick={onSearch} - className="inline-flex h-8 items-center gap-2 rounded-[1px] border border-line px-2 text-sm text-ink-faint transition-colors duration-150 hover:border-line-strong hover:text-ink" - aria-label="搜索" - > - <Search className="size-4" aria-hidden /> - </button> + <SearchButton onClick={onSearch} compact /> </div> </div> - <button - type="button" - onClick={onSearch} - className="mx-5 mb-4 hidden h-9 w-[calc(100%-2.5rem)] items-center gap-2 rounded-[1px] border border-line px-2.5 text-sm text-ink-faint transition-colors duration-150 hover:border-line-strong hover:text-ink lg:flex" - > - <Search className="size-4" aria-hidden /> - 搜索 - <kbd className="ml-auto font-mono text-xs">⌘K</kbd> - </button> - <nav - aria-label="主导航" - className="flex gap-1 overflow-x-auto px-3 pb-2 lg:flex-col lg:gap-0.5 lg:px-4 lg:pb-0" - > - <NavItem to="/">首页</NavItem> - <NavItem to="/notes">Notes</NavItem> - <NavItem to="/topics">Topics</NavItem> - <NavItem to="/tags">Tags</NavItem> - <NavItem to="/stats">统计</NavItem> - {problemCount > 0 && ( - <NavItem to="/problems"> - 需要处理 - <span className="ml-1.5 rounded-[1px] bg-error px-1 text-xs text-on-brand tabular-nums"> - {problemCount} - </span> - </NavItem> - )} + + <nav aria-label="主导航" className="px-4 pt-4 lg:px-5"> + <div className="grid grid-cols-2 rounded-panel border border-line-strong p-0.5"> + <TabLink to="/" active={tab === "distill"}> + Distill + </TabLink> + <TabLink to="/review" active={tab === "review"}> + Review + </TabLink> + </div> </nav> - <div className="hidden px-4 lg:absolute lg:bottom-4 lg:block"> + + <div className="hidden px-5 pt-3 lg:block"> + <SearchButton onClick={onSearch} /> + </div> + + <TagList activeTag={activeTag} /> + + <div className="hidden items-center justify-between gap-2 border-t border-line px-4 py-3 lg:flex"> <ThemeToggle /> + {problemCount > 0 && ( + <Link + to="/problems" + className="inline-flex items-center gap-1.5 rounded-[1px] px-2 py-1 text-xs text-error hover:bg-error-soft" + > + <TriangleAlert className="size-3.5" aria-hidden /> + {problemCount} 需要处理 + </Link> + )} </div> </aside> ); } -function NavItem({ to, children }: { to: string; children: ReactNode }) { +function TabLink({ + to, + active, + children, +}: { + to: "/" | "/review"; + active: boolean; + children: string; +}) { return ( <Link to={to} - activeOptions={{ exact: to === "/" }} - className="flex shrink-0 items-center py-1 text-sm lg:py-0.5" - > - {({ isActive }) => ( - <span - className={ - isActive - ? "rounded-[1px] bg-brand-strong px-2 py-1 text-on-brand" - : "rounded-[1px] px-2 py-1 text-ink-muted transition-colors duration-150 hover:text-ink" - } - > - {children} - </span> + aria-current={active ? "page" : undefined} + className={cx( + "rounded-[1px] py-1.5 text-center text-sm transition-colors duration-150", + active + ? "bg-brand-strong text-on-brand" + : "text-ink-muted hover:bg-panel-hover hover:text-ink", )} + > + {children} </Link> ); } +function SearchButton({ onClick, compact }: { onClick: () => void; compact?: boolean }) { + return ( + <button + type="button" + onClick={onClick} + aria-label="搜索" + className={cx( + "flex h-8 items-center gap-2 rounded-[1px] border border-line text-sm text-ink-faint transition-colors duration-150 hover:border-line-strong hover:text-ink", + compact ? "px-2" : "w-full px-2.5", + )} + > + <Search className="size-4" aria-hidden /> + {!compact && ( + <> + 搜索 + <kbd className="ml-auto font-mono text-xs">⌘K</kbd> + </> + )} + </button> + ); +} + +/** Every tag, most used first. Choosing one filters the Distill timeline. */ +function TagList({ activeTag }: { activeTag: string | undefined }) { + const tags = useQuery(q.tags()); + const items = tags.data ?? []; + return ( + <section aria-label="Tags" className="min-h-0 lg:flex lg:flex-1 lg:flex-col lg:pt-6"> + <h2 className="hidden px-5 pb-2 font-body text-[11px] tracking-wider text-ink-faint uppercase lg:block"> + Tags + </h2> + <ul className="flex gap-1.5 overflow-x-auto px-4 py-3 lg:block lg:flex-1 lg:space-y-px lg:overflow-y-auto lg:px-3 lg:py-0 lg:pb-4"> + {items.map((t) => { + const active = t.tag === activeTag; + return ( + <li key={t.tag} className="shrink-0"> + <Link + to="/" + search={active ? {} : { tag: t.tag }} + aria-current={active ? "true" : undefined} + className={cx( + "flex items-center gap-2 rounded-[1px] border px-2 py-1 text-sm transition-colors duration-150 lg:border-0", + active + ? "border-brand bg-brand-soft text-brand-ink" + : "border-line text-ink-muted hover:bg-panel-hover hover:text-ink", + )} + > + <span className="truncate">#{t.tag}</span> + <span className="ml-auto text-xs text-ink-faint tabular-nums">{t.notes}</span> + </Link> + </li> + ); + })} + {tags.isSuccess && items.length === 0 && ( + <li className="px-2 text-sm text-ink-faint">还没有 tag</li> + )} + </ul> + </section> + ); +} + function AuthRequired() { return ( <div className="flex min-h-screen items-center justify-center px-4"> diff --git a/apps/web/src/components/NoteList.tsx b/apps/web/src/components/NoteList.tsx deleted file mode 100644 index e167b54..0000000 --- a/apps/web/src/components/NoteList.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { Link } from "@tanstack/react-router"; -import { WindowVirtualizer } from "virtua"; -import type { NoteHit } from "../api/generated/NoteHit"; -import { excerpt, formatDate } from "../lib/format"; -import { Empty, TagChip } from "./ui"; - -/** Notes as rows; long lists render only what is on screen. */ -export function NoteList({ notes, empty }: { notes: NoteHit[]; empty: string }) { - if (notes.length === 0) { - return <Empty>{empty}</Empty>; - } - return ( - <ol className="border-t border-line"> - <WindowVirtualizer> - {notes.map((note) => ( - <li key={note.id} className="border-b border-line"> - <NoteRow note={note} /> - </li> - ))} - </WindowVirtualizer> - </ol> - ); -} - -function NoteRow({ note }: { note: NoteHit }) { - return ( - <div className="group relative px-1 py-3 transition-colors duration-150 hover:bg-panel-hover sm:px-3"> - <div className="flex items-baseline justify-between gap-4"> - <Link - to="/notes/$noteId" - params={{ noteId: note.id }} - className="font-display text-[1.0625rem] leading-snug text-ink after:absolute after:inset-0 group-hover:text-brand-ink" - > - {note.title} - </Link> - <time className="shrink-0 text-xs text-ink-faint tabular-nums" dateTime={note.created}> - {formatDate(note.created)} - </time> - </div> - <p className="mt-1 line-clamp-2 text-sm text-ink-muted">{excerpt(note.conclusion)}</p> - {note.tags.length > 0 && ( - <div className="relative z-10 mt-2 flex flex-wrap gap-1.5"> - {note.tags.map((tag) => ( - <TagChip key={tag} tag={tag} /> - ))} - </div> - )} - </div> - ); -} diff --git a/apps/web/src/components/TopicRows.tsx b/apps/web/src/components/TopicRows.tsx index fbdb39a..7b32435 100644 --- a/apps/web/src/components/TopicRows.tsx +++ b/apps/web/src/components/TopicRows.tsx @@ -3,7 +3,8 @@ import type { TopicCount } from "../api/generated/TopicCount"; import { formatDate } from "../lib/format"; import { AskCount, Empty } from "./ui"; -export function TopicRows({ topics }: { topics: TopicCount[] }) { +/** `compact` drops the date and annotation columns for narrow cards. */ +export function TopicRows({ topics, compact }: { topics: TopicCount[]; compact?: boolean }) { if (topics.length === 0) { return <Empty>没有 topic。</Empty>; } @@ -19,14 +20,16 @@ export function TopicRows({ topics }: { topics: TopicCount[] }) { <span className="min-w-0 flex-1 truncate text-ink group-hover:text-brand-ink"> {t.label} </span> - {!t.annotated && t.ask_count >= 2 && ( + {!compact && !t.annotated && t.ask_count >= 2 && ( <span className="hidden shrink-0 text-xs text-ink-faint sm:inline"> 无 annotation </span> )} - <span className="hidden shrink-0 text-xs text-ink-faint tabular-nums sm:inline"> - {formatDate(t.last_asked)} - </span> + {!compact && ( + <span className="hidden shrink-0 text-xs text-ink-faint tabular-nums sm:inline"> + {formatDate(t.last_asked)} + </span> + )} <AskCount count={t.ask_count} /> </Link> </li> diff --git a/apps/web/src/components/charts.tsx b/apps/web/src/components/charts.tsx index 09924ed..cba7871 100644 --- a/apps/web/src/components/charts.tsx +++ b/apps/web/src/components/charts.tsx @@ -93,19 +93,13 @@ export function Columns({ columns, unit }: { columns: Column[]; unit: string }) export type Bar = { key: string; label: string; value: number }; -/** Horizontal bars for a ranking (tags, projects), value at the tip. */ -export function BarList({ - bars, - link, -}: { - bars: Bar[]; - link?: (bar: Bar) => { to: string; params?: Record<string, string> }; -}) { +/** Horizontal bars for a ranking (tags, projects, agents), value at the tip. With + * `tagLinks`, each label filters the Distill timeline by that tag. */ +export function BarList({ bars, tagLinks }: { bars: Bar[]; tagLinks?: boolean }) { const max = Math.max(1, ...bars.map((b) => b.value)); return ( <ul className="space-y-1"> {bars.map((b) => { - const target = link?.(b); const label = ( <span className="block truncate text-sm text-ink" title={b.label}> {b.label} @@ -114,12 +108,12 @@ export function BarList({ return ( <li key={b.key} - className="grid grid-cols-[minmax(0,10rem)_minmax(0,1fr)] items-center gap-3" + className="grid grid-cols-[minmax(0,8rem)_minmax(0,1fr)] items-center gap-3" > - {target ? ( + {tagLinks ? ( <Link - to={target.to} - params={target.params} + to="/" + search={{ tag: b.key }} className="min-w-0 hover:[&>span]:text-brand-ink" > {label} @@ -128,11 +122,15 @@ export function BarList({ label )} <span className="flex items-center gap-2"> - <span - className="block h-3 rounded-r-[2px] bg-brand" - style={{ width: `calc(${(b.value / max) * 100}% - 2.5rem)` }} - /> - <span className="text-xs text-ink-muted tabular-nums">{b.value}</span> + <span className="h-3 min-w-0 flex-1"> + <span + className="block h-3 rounded-r-[2px] bg-brand" + style={{ width: `${(b.value / max) * 100}%` }} + /> + </span> + <span className="w-7 shrink-0 text-right text-xs text-ink-muted tabular-nums"> + {b.value} + </span> </span> </li> ); @@ -141,6 +139,108 @@ export function BarList({ ); } +export type Day = { day: string; label: string; value: number; future: boolean }; + +const CELL = 12; +/** Light to dark steps of the brand hue, mixed into the panel so both themes work. */ +const LEVELS = [30, 55, 78, 100]; + +function level(value: number, max: number): string { + if (value <= 0) { + return "var(--color-line)"; + } + const step = LEVELS[Math.min(LEVELS.length - 1, Math.ceil((value / max) * LEVELS.length) - 1)]; + return `color-mix(in oklch, var(--color-brand) ${step}%, var(--color-panel))`; +} + +/** + * Notes per day as a calendar grid: one column per week (Monday on top), one sequential + * hue. Hover shows the day's count; the table below carries every value. + */ +/** "9月" above the first column of each month, blank elsewhere. The leading partial + * month gets no label when the next one starts within three columns, so labels never + * collide. */ +function monthStart(weeks: Day[][], i: number): string { + const month = (w: Day[] | undefined) => w?.[0]?.day.slice(0, 7); + if (i > 0 && month(weeks[i]) === month(weeks[i - 1])) { + return ""; + } + if (i === 0 && [1, 2].some((j) => month(weeks[j]) !== month(weeks[0]))) { + return ""; + } + const day = weeks[i]?.[0]?.day; + return day ? `${Number(day.slice(5, 7))}月` : ""; +} + +export function Heatmap({ weeks, unit }: { weeks: Day[][]; unit: string }) { + const [active, setActive] = useState<Day | null>(null); + const max = Math.max(1, ...weeks.flat().map((d) => d.value)); + return ( + <figure> + <div className="overflow-x-auto pb-1"> + <div className="relative inline-flex gap-[2px]" onPointerLeave={() => setActive(null)}> + {weeks.map((week, i) => ( + <div key={week[0]?.day} className="flex flex-col gap-[2px]"> + <span className="h-4 text-[10px] leading-none whitespace-nowrap text-ink-faint"> + {monthStart(weeks, i)} + </span> + {week.map((d) => ( + <span + key={d.day} + onPointerEnter={() => setActive(d)} + className={cx( + "block rounded-[2px]", + active?.day === d.day && "outline-1 outline-offset-1 outline-ink", + )} + style={{ + width: CELL, + height: CELL, + background: d.future ? "transparent" : level(d.value, max), + }} + /> + ))} + </div> + ))} + </div> + </div> + <figcaption className="mt-2 flex flex-wrap items-center gap-3 text-xs text-ink-faint"> + <span className="min-h-4 text-ink" aria-live="polite"> + {active ? ( + <> + <strong className="tabular-nums"> + {active.value} {unit} + </strong>{" "} + <span className="text-ink-faint">{active.label}</span> + </> + ) : ( + "悬停查看某一天" + )} + </span> + <span className="ml-auto flex items-center gap-1"> + 少 + {[0, ...LEVELS.map((_, i) => ((i + 1) / LEVELS.length) * max)].map((v) => ( + <span + key={v} + className="inline-block rounded-[2px]" + style={{ width: CELL, height: CELL, background: level(v, max) }} + /> + ))} + 多 + </span> + </figcaption> + <DataTable + head={["日期", unit]} + rows={weeks + .flat() + .filter((d) => d.value > 0) + .reverse() + .map((d) => [d.label, d.value])} + className="mt-3" + /> + </figure> + ); +} + function DataTable({ head, rows, diff --git a/apps/web/src/components/ui.tsx b/apps/web/src/components/ui.tsx index 8dfdced..9261c04 100644 --- a/apps/web/src/components/ui.tsx +++ b/apps/web/src/components/ui.tsx @@ -125,8 +125,8 @@ export function AskCount({ count, className }: { count: number; className?: stri export function TagChip({ tag }: { tag: string }) { return ( <Link - to="/tags/$tag" - params={{ tag }} + to="/" + search={{ tag }} className="inline-flex items-center rounded-[1px] border border-line px-1.5 py-px text-xs text-ink-muted transition-colors duration-150 hover:border-brand hover:text-brand-ink" > #{tag} @@ -157,7 +157,7 @@ export function CopyButton({ text, label }: { text: string; label: string }) { export function Stat({ label, value, hint }: { label: string; value: ReactNode; hint?: string }) { return ( <div className="rounded-panel border border-line bg-panel px-4 py-3"> - <div className="text-xs uppercase tracking-wider text-ink-faint">{label}</div> + <div className="text-xs text-ink-faint">{label}</div> <div className="mt-1 text-3xl font-bold text-ink">{value}</div> {hint && <div className="mt-1 text-xs text-ink-faint">{hint}</div>} </div> diff --git a/apps/web/src/lib/format.test.ts b/apps/web/src/lib/format.test.ts index 32e3b4a..6a137cb 100644 --- a/apps/web/src/lib/format.test.ts +++ b/apps/web/src/lib/format.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { excerpt, formatAgo, projectName, stripTitle } from "./format"; +import { dayLabel, excerpt, formatAgo, projectName, stripTitle, withoutSection } from "./format"; describe("formatAgo", () => { const now = new Date("2026-09-20T12:00:00Z"); @@ -35,3 +35,22 @@ describe("excerpt", () => { ).toBe("不能直接用:bun:sqlite 很慢。 文档"); }); }); + +describe("dayLabel", () => { + const now = new Date("2026-09-26T10:00:00"); + it("names today and yesterday", () => { + expect(dayLabel("2026-09-26", now)).toBe("今天"); + expect(dayLabel("2026-09-25", now)).toBe("昨天"); + expect(dayLabel("2026-09-13", now)).toContain("9月13日"); + expect(dayLabel("2025-12-31", now)).toContain("2025"); + }); +}); + +describe("withoutSection", () => { + it("drops one section and keeps the rest", () => { + const md = "## 问题\n\nq\n\n## 结论\n\nc\n"; + expect(withoutSection(md, "问题")).toBe("## 结论\n\nc\n"); + expect(withoutSection(md, "结论")).toBe("## 问题\n\nq\n"); + expect(withoutSection(md, "要点")).toBe(md); + }); +}); diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts index a5bc541..e4ac8e9 100644 --- a/apps/web/src/lib/format.ts +++ b/apps/web/src/lib/format.ts @@ -73,3 +73,51 @@ export function excerpt(markdown: string): string { .replace(/\s+/g, " ") .trim(); } + +const timeFormat = new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit" }); +const monthDay = new Intl.DateTimeFormat("zh-CN", { + month: "long", + day: "numeric", + weekday: "short", +}); +const yearMonthDay = new Intl.DateTimeFormat("zh-CN", { + year: "numeric", + month: "long", + day: "numeric", + weekday: "short", +}); + +export function formatTime(rfc3339: string): string { + return timeFormat.format(new Date(rfc3339)); +} + +/** `YYYY-MM-DD` of a date in the viewer's time zone. */ +export function localDay(date: Date): string { + const pad = (n: number) => String(n).padStart(2, "0"); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +} + +/** Heading for a day on the timeline: 今天, 昨天, or the date. */ +export function dayLabel(day: string, now: Date = new Date()): string { + const yesterday = new Date(now); + yesterday.setDate(now.getDate() - 1); + if (day === localDay(now)) { + return "今天"; + } + if (day === localDay(yesterday)) { + return "昨天"; + } + const date = new Date(`${day}T12:00:00`); + return (date.getFullYear() === now.getFullYear() ? monthDay : yearMonthDay).format(date); +} + +/** A note body without one `## <heading>` section. */ +export function withoutSection(markdown: string, heading: string): string { + const lines = markdown.split("\n"); + const start = lines.findIndex((l) => l.trim() === `## ${heading}`); + if (start < 0) { + return markdown; + } + const next = lines.findIndex((l, i) => i > start && l.startsWith("## ")); + return [...lines.slice(0, start), ...(next < 0 ? [] : lines.slice(next))].join("\n"); +} diff --git a/apps/web/src/lib/weeks.test.ts b/apps/web/src/lib/weeks.test.ts index 0f6a3cf..dfea974 100644 --- a/apps/web/src/lib/weeks.test.ts +++ b/apps/web/src/lib/weeks.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { weekKey, weeksEnding } from "./weeks"; +import { calendarWeeks, weekKey, weeksEnding } from "./weeks"; describe("ISO weeks", () => { it("numbers weeks like the server", () => { @@ -13,3 +13,13 @@ describe("ISO weeks", () => { expect(weeks).toEqual(["2026-W52", "2026-W53", "2027-W01", "2027-W02"]); }); }); + +describe("calendarWeeks", () => { + it("ends with the week holding today, Monday first", () => { + const weeks = calendarWeeks(3, new Date(2026, 8, 26)); // Saturday 2026-09-26 + expect(weeks).toHaveLength(3); + expect(weeks[0]?.[0]?.getDay()).toBe(1); + expect(weeks[2]?.[0]?.getDate()).toBe(21); + expect(weeks[2]?.[6]?.getDate()).toBe(27); + }); +}); diff --git a/apps/web/src/lib/weeks.ts b/apps/web/src/lib/weeks.ts index a84f01c..b3cae74 100644 --- a/apps/web/src/lib/weeks.ts +++ b/apps/web/src/lib/weeks.ts @@ -31,3 +31,19 @@ export function weeksEnding(last: string, count: number): { key: string; monday: return { key: weekKey(monday), monday }; }); } + +/** + * The last `count` calendar weeks up to `now`, Monday first, as local `YYYY-MM-DD` days. + * Days after `now` are included so every column has seven cells. + */ +export function calendarWeeks(count: number, now: Date = new Date()): Date[][] { + const monday = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + monday.setDate(monday.getDate() - ((monday.getDay() + 6) % 7) - (count - 1) * 7); + return Array.from({ length: count }, (_, w) => + Array.from({ length: 7 }, (_, d) => { + const day = new Date(monday); + day.setDate(monday.getDate() + w * 7 + d); + return day; + }), + ); +} diff --git a/apps/web/src/pages/DistillPage.tsx b/apps/web/src/pages/DistillPage.tsx new file mode 100644 index 0000000..7525064 --- /dev/null +++ b/apps/web/src/pages/DistillPage.tsx @@ -0,0 +1,235 @@ +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; +import { Link, useSearch } from "@tanstack/react-router"; +import { ArrowRight, ChevronDown, MessageSquareText, X } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import type { TimelineItem } from "../api/generated/TimelineItem"; +import { q } from "../api/queries"; +import { Markdown } from "../components/Markdown"; +import { AskCount, Button, cx, Empty, ErrorBox, Loading, TagChip } from "../components/ui"; +import { + agentLabel, + dayLabel, + excerpt, + formatTime, + localDay, + projectName, + stripTitle, + withoutSection, +} from "../lib/format"; + +/** Every distilled note, newest first, grouped by day. */ +export function DistillPage() { + const { tag } = useSearch({ from: "/" }); + const timeline = useInfiniteQuery(q.timeline(tag)); + const items = timeline.data?.pages.flatMap((p) => p.items) ?? []; + + return ( + <div className="mx-auto max-w-[48rem]"> + <header className="mb-8"> + <h1 className="text-[1.875rem] leading-tight sm:text-4xl">{tag ? `#${tag}` : "Distill"}</h1> + <p className="mt-2 flex flex-wrap items-center gap-3 text-sm text-ink-faint"> + {tag ? "这个 tag 下的 note,最新的在前。" : "你 distill 过的每个问题,最新的在前。"} + {tag && ( + <Link to="/" className="inline-flex items-center gap-1 text-brand-ink hover:underline"> + <X className="size-3.5" aria-hidden /> + 看全部 + </Link> + )} + </p> + </header> + {timeline.isPending ? ( + <Loading /> + ) : timeline.isError ? ( + <ErrorBox error={timeline.error} /> + ) : items.length === 0 ? ( + <Empty> + {tag + ? "这个 tag 下没有 note。" + : "还没有 note。在 Codex 或 Claude Code 里聊完一个值得记住的问题后,说“distill 一下”。"} + </Empty> + ) : ( + <> + <Timeline items={items} /> + <MorePages + hasMore={timeline.hasNextPage} + loading={timeline.isFetchingNextPage} + onMore={() => void timeline.fetchNextPage()} + /> + </> + )} + </div> + ); +} + +function Timeline({ items }: { items: TimelineItem[] }) { + const days: { day: string; items: TimelineItem[] }[] = []; + for (const item of items) { + const day = localDay(new Date(item.created)); + const last = days.at(-1); + if (last?.day === day) { + last.items.push(item); + } else { + days.push({ day, items: [item] }); + } + } + return ( + <div className="space-y-10"> + {days.map(({ day, items }) => ( + <section key={day} aria-label={dayLabel(day)}> + <h2 className="mb-4 font-body text-sm text-ink-faint">{dayLabel(day)}</h2> + <ol className="relative ml-1.5 border-l border-line-strong"> + {items.map((item) => ( + <li key={item.id} className="relative pb-6 pl-6 last:pb-0"> + <span + className={cx( + "absolute top-[1.15rem] -left-[5px] size-[9px] border", + item.ask_index >= 2 ? "border-warning bg-warning" : "border-brand bg-panel", + )} + aria-hidden + /> + <Entry item={item} /> + </li> + ))} + </ol> + </section> + ))} + </div> + ); +} + +/** + * The question and the first two lines of what was distilled. Activating it loads and + * shows the whole note in place. + */ +function Entry({ item }: { item: TimelineItem }) { + const [open, setOpen] = useState(false); + const bodyId = `entry-${item.id}`; + return ( + <article + className={cx( + "rounded-panel border transition-colors duration-150", + open ? "border-line-strong bg-panel" : "border-transparent hover:bg-panel-hover", + )} + > + <button + type="button" + aria-expanded={open} + aria-controls={bodyId} + onClick={() => setOpen(!open)} + className="block w-full px-3 py-3 text-left" + > + <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-ink-faint"> + <time dateTime={item.created} className="tabular-nums"> + {formatTime(item.created)} + </time> + <span>{agentLabel[item.source.agent]}</span> + <span className="min-w-0 truncate">{projectName(item.source)}</span> + {item.ask_count >= 2 && ( + <span className="rounded-[1px] bg-warning-soft px-1.5 py-px text-warning"> + 第 {item.ask_index} 次问 · 共 {item.ask_count} 次 + </span> + )} + <ChevronDown + className={cx("ml-auto size-4 transition-transform duration-150", open && "rotate-180")} + aria-hidden + /> + </div> + <h3 className="mt-1.5 text-lg leading-snug">{item.title}</h3> + <p className="mt-2 flex gap-2 text-[0.9375rem] text-ink"> + <span className="mt-0.5 shrink-0 text-xs text-ink-faint">问</span> + <span className="line-clamp-3">{item.question}</span> + </p> + {!open && ( + <p className="mt-1.5 flex gap-2 text-[0.9375rem] text-ink-muted"> + <span className="mt-0.5 shrink-0 text-xs text-brand-ink">答</span> + <span className="line-clamp-2">{excerpt(item.conclusion)}</span> + </p> + )} + </button> + <div id={bodyId} hidden={!open} className="px-3 pb-4"> + {open && <FullNote item={item} />} + </div> + {item.tags.length > 0 && ( + <div className="flex flex-wrap gap-1.5 px-3 pb-3"> + {item.tags.map((tag) => ( + <TagChip key={tag} tag={tag} /> + ))} + </div> + )} + </article> + ); +} + +function FullNote({ item }: { item: TimelineItem }) { + const note = useQuery(q.note(item.id)); + if (note.isPending) { + return <Loading />; + } + if (note.isError) { + return <ErrorBox error={note.error} />; + } + return ( + <div className="border-t border-line pt-2"> + <Markdown source={withoutSection(stripTitle(note.data.markdown), "问题")} /> + <div className="mt-5 flex flex-wrap items-center gap-x-5 gap-y-2 text-sm"> + <Link + to="/notes/$noteId" + params={{ noteId: item.id }} + className="inline-flex items-center gap-1 text-brand-ink hover:underline" + > + 打开 note 页 + <ArrowRight className="size-3.5" aria-hidden /> + </Link> + <Link + to="/topics/$topicId" + params={{ topicId: item.topic_id }} + className="inline-flex min-w-0 items-center gap-2 text-ink-muted hover:text-brand-ink" + > + <span className="truncate">Topic:{item.topic_label}</span> + <AskCount count={item.ask_count} /> + </Link> + <span className="inline-flex items-center gap-1 text-ink-faint"> + <MessageSquareText className="size-3.5" aria-hidden /> + {item.annotations > 0 ? `${item.annotations} 条 annotation` : "还没有 annotation"} + </span> + </div> + </div> + ); +} + +/** Loads the next page when the end of the list scrolls into view, or on click. */ +function MorePages({ + hasMore, + loading, + onMore, +}: { + hasMore: boolean; + loading: boolean; + onMore: () => void; +}) { + const sentinel = useRef<HTMLDivElement>(null); + useEffect(() => { + const el = sentinel.current; + if (!el || !hasMore) { + return; + } + const observer = new IntersectionObserver((entries) => { + if (entries.some((e) => e.isIntersecting) && !loading) { + onMore(); + } + }); + observer.observe(el); + return () => observer.disconnect(); + }, [hasMore, loading, onMore]); + + if (!hasMore) { + return null; + } + return ( + <div ref={sentinel} className="mt-8 flex justify-center"> + <Button disabled={loading} onClick={onMore}> + {loading ? "载入中…" : "更早的 note"} + </Button> + </div> + ); +} diff --git a/apps/web/src/pages/HomePage.tsx b/apps/web/src/pages/HomePage.tsx deleted file mode 100644 index f0df5cc..0000000 --- a/apps/web/src/pages/HomePage.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { Link } from "@tanstack/react-router"; -import { ArrowRight, GitMerge, TriangleAlert } from "lucide-react"; -import { q } from "../api/queries"; -import { NoteList } from "../components/NoteList"; -import { TopicRows } from "../components/TopicRows"; -import { Empty, ErrorBox, Loading, PageHeader, Section } from "../components/ui"; - -const RECENT = 8; -const TOP = 6; - -export function HomePage() { - const stats = useQuery(q.stats()); - const recent = useQuery(q.notes({ limit: RECENT })); - const problems = useQuery(q.problems()); - const duplicates = useQuery(q.duplicates()); - const duplicateCount = duplicates.data?.pairs.length ?? 0; - - if (stats.isPending || recent.isPending) { - return <Loading />; - } - if (stats.isError) { - return <ErrorBox error={stats.error} />; - } - if (recent.isError) { - return <ErrorBox error={recent.error} />; - } - const s = stats.data; - const repeated = s.top_topics.filter((t) => t.ask_count >= 2); - const undigested = repeated.filter((t) => !t.annotated); - const problemCount = - (problems.data?.conflicts.length ?? 0) + (problems.data?.invalid_files.length ?? 0); - - if (s.notes === 0) { - return ( - <> - <PageHeader title="Distill" /> - <Empty> - 还没有 note。在 Codex 或 Claude Code 里聊完一个值得记住的问题后,说“distill 一下”。 - </Empty> - </> - ); - } - return ( - <div> - <PageHeader title="Distill"> - {s.notes} 条 note,{s.topics} 个 topic,其中 {s.repeated_topics} 个问过不止一次。 - </PageHeader> - {problemCount > 0 && ( - <Link - to="/problems" - className="mb-8 flex items-center gap-3 rounded-panel border border-error/40 bg-error-soft px-4 py-3 text-sm text-error" - > - <TriangleAlert className="size-4 shrink-0" aria-hidden />有 {problemCount}{" "} - 个文件需要处理:同步冲突或读不了的文件。 - <ArrowRight className="ml-auto size-4" aria-hidden /> - </Link> - )} - {duplicateCount > 0 && ( - <Link - to="/topics" - search={{ view: "duplicates" }} - className="mb-8 flex items-center gap-3 rounded-panel border border-warning/40 bg-warning-soft px-4 py-3 text-sm text-warning" - > - <GitMerge className="size-4 shrink-0" aria-hidden /> - {duplicateCount} 对 topic 看起来是同一个问题,合并后提问次数才准。 - <ArrowRight className="ml-auto size-4" aria-hidden /> - </Link> - )} - <Section title="问得最多" action={<More to="/topics" />}> - {repeated.length > 0 ? ( - <TopicRows topics={repeated.slice(0, TOP)} /> - ) : ( - <Empty>还没有问过第二次的问题。</Empty> - )} - </Section> - {undigested.length > 0 && ( - <Section title="重问过、还没写 annotation"> - <p className="mb-3 text-sm text-ink-faint"> - 问了不止一次却没留下自己的理解,这些最可能还没真正弄懂。 - </p> - <TopicRows topics={undigested.slice(0, TOP)} /> - </Section> - )} - <Section title="最近的 note" action={<More to="/notes" />}> - <NoteList notes={recent.data} empty="还没有 note。" /> - </Section> - </div> - ); -} - -function More({ to }: { to: string }) { - return ( - <Link to={to} className="text-sm text-brand-ink hover:underline"> - 全部 → - </Link> - ); -} diff --git a/apps/web/src/pages/NotesPage.tsx b/apps/web/src/pages/NotesPage.tsx deleted file mode 100644 index 489ce2e..0000000 --- a/apps/web/src/pages/NotesPage.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { useNavigate, useSearch } from "@tanstack/react-router"; -import { Search } from "lucide-react"; -import { useDeferredValue, useState } from "react"; -import { q } from "../api/queries"; -import { NoteList } from "../components/NoteList"; -import { ErrorBox, Loading, PageHeader } from "../components/ui"; - -const LIMIT = 500; - -export function NotesPage() { - const search = useSearch({ from: "/notes" }); - const navigate = useNavigate({ from: "/notes" }); - const [input, setInput] = useState(search.q ?? ""); - const text = useDeferredValue(input.trim()); - const notes = useQuery(q.notes({ q: text, limit: LIMIT })); - - return ( - <div> - <PageHeader title="Notes" /> - <label className="mb-6 flex items-center gap-2 rounded-panel border border-line-strong bg-panel px-3 focus-within:border-brand"> - <Search className="size-4 text-ink-faint" aria-hidden /> - <input - value={input} - onChange={(event) => { - setInput(event.target.value); - void navigate({ search: { q: event.target.value || undefined }, replace: true }); - }} - placeholder="关键词:标题、问题、正文" - aria-label="搜索 note" - className="h-10 w-full bg-transparent text-ink outline-none placeholder:text-ink-faint" - /> - </label> - {notes.isPending ? ( - <Loading /> - ) : notes.isError ? ( - <ErrorBox error={notes.error} /> - ) : ( - <> - <p className="mb-3 text-sm text-ink-faint"> - {text ? `${notes.data.length} 条匹配` : `最近的 ${notes.data.length} 条`} - </p> - <NoteList notes={notes.data} empty={text ? "没有匹配的 note。" : "还没有 note。"} /> - </> - )} - </div> - ); -} diff --git a/apps/web/src/pages/ReviewPage.tsx b/apps/web/src/pages/ReviewPage.tsx new file mode 100644 index 0000000..b78b752 --- /dev/null +++ b/apps/web/src/pages/ReviewPage.tsx @@ -0,0 +1,203 @@ +import { useQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import { ArrowRight, TriangleAlert } from "lucide-react"; +import type { ReactNode } from "react"; +import type { Stats } from "../api/generated/Stats"; +import { q } from "../api/queries"; +import { BarList, Columns, type Day, Heatmap } from "../components/charts"; +import { DuplicateList } from "../components/Duplicates"; +import { TopicRows } from "../components/TopicRows"; +import { Empty, ErrorBox, Loading, PageHeader, Stat } from "../components/ui"; +import { agentLabel, localDay, projectName } from "../lib/format"; +import { calendarWeeks, weekKey, weeksEnding } from "../lib/weeks"; + +const HEATMAP_WEEKS = 26; +const COLUMN_WEEKS = 16; +const ACTIVE_WINDOW_DAYS = 30; +const TOP = 8; +const BARS = 10; + +const dayTitle = new Intl.DateTimeFormat("zh-CN", { + year: "numeric", + month: "long", + day: "numeric", + weekday: "short", +}); + +/** How the user has been learning: activity over time, repeats, and where it happens. */ +export function ReviewPage() { + const stats = useQuery(q.stats()); + const problems = useQuery(q.problems()); + if (stats.isPending) { + return <Loading />; + } + if (stats.isError) { + return <ErrorBox error={stats.error} />; + } + const s = stats.data; + const problemCount = + (problems.data?.conflicts.length ?? 0) + (problems.data?.invalid_files.length ?? 0); + const repeated = s.top_topics.filter((t) => t.ask_count >= 2); + const undigested = repeated.filter((t) => !t.annotated); + + return ( + <div> + <PageHeader title="Review"> + {s.notes} 条 note,{s.topics} 个 topic,{s.annotations} 条 annotation。 + </PageHeader> + {problemCount > 0 && ( + <Link + to="/problems" + className="mb-8 flex items-center gap-3 rounded-panel border border-error/40 bg-error-soft px-4 py-3 text-sm text-error" + > + <TriangleAlert className="size-4 shrink-0" aria-hidden />有 {problemCount}{" "} + 个文件需要处理:同步冲突或读不了的文件。 + <ArrowRight className="ml-auto size-4" aria-hidden /> + </Link> + )} + <Metrics stats={s} /> + + <Card title={`活动(最近 ${HEATMAP_WEEKS} 周)`}> + <Heatmap weeks={heatmap(s)} unit="条 note" /> + </Card> + + <Card title={`每周 distill 的 note(最近 ${COLUMN_WEEKS} 周)`}> + <Columns columns={weekly(s)} unit="条" /> + </Card> + + <div className="grid gap-x-6 lg:grid-cols-2"> + <Card title="问得最多的 topic"> + {repeated.length > 0 ? ( + <TopicRows compact topics={repeated.slice(0, TOP)} /> + ) : ( + <Empty>还没有问过第二次的问题。</Empty> + )} + </Card> + <Card title="重问过、还没写 annotation"> + {undigested.length > 0 ? ( + <TopicRows compact topics={undigested.slice(0, TOP)} /> + ) : ( + <Empty>重问过的 topic 都写了 annotation。</Empty> + )} + </Card> + </div> + + <Card title="可能是同一个问题的 topic"> + <DuplicateList /> + </Card> + + <div className="grid gap-x-6 lg:grid-cols-2"> + <Card title="按 tag"> + <BarList + tagLinks + bars={s.tags.slice(0, BARS).map((t) => ({ + key: t.tag, + label: `#${t.tag}`, + value: t.notes, + }))} + /> + </Card> + <Card title="按项目"> + <BarList + bars={s.by_project.slice(0, BARS).map((p) => ({ + key: p.project, + label: projectName({ cwd: p.project }), + value: p.notes, + }))} + /> + </Card> + <Card title="按 agent"> + <BarList + bars={s.by_agent.map((a) => ({ + key: a.agent, + label: agentLabel[a.agent], + value: a.notes, + }))} + /> + </Card> + </div> + </div> + ); +} + +function Metrics({ stats: s }: { stats: Stats }) { + const now = new Date(); + const perWeek = new Map(s.by_week.map((w) => [w.week, w.notes])); + const [lastWeek, thisWeek] = weeksEnding(weekKey(now), 2); + const current = perWeek.get(thisWeek?.key ?? "") ?? 0; + const previous = perWeek.get(lastWeek?.key ?? "") ?? 0; + + const since = new Date(now); + since.setDate(now.getDate() - (ACTIVE_WINDOW_DAYS - 1)); + const activeDays = s.by_day.filter((d) => d.day >= localDay(since)).length; + + return ( + <div className="grid grid-cols-2 gap-3 lg:grid-cols-4"> + <Stat + label="本周 distill" + value={current} + hint={`上周 ${previous}${delta(current, previous)}`} + /> + <Stat + label="重问过的 topic" + value={s.repeated_topics} + hint={`占全部 topic 的 ${percent(s.repeated_topics, s.topics)}`} + /> + <Stat + label="annotation 覆盖" + value={percent(s.annotated_repeated_topics, s.repeated_topics)} + hint={`重问过的 ${s.repeated_topics} 个 topic 中写了 ${s.annotated_repeated_topics} 个`} + /> + <Stat + label={`近 ${ACTIVE_WINDOW_DAYS} 天活跃`} + value={`${activeDays} 天`} + hint="有 distill 的天数" + /> + </div> + ); +} + +function Card({ title, children }: { title: string; children: ReactNode }) { + return ( + <section className="mt-6 rounded-panel border border-line bg-panel p-4 sm:p-5"> + <h2 className="mb-4 text-lg">{title}</h2> + {children} + </section> + ); +} + +function heatmap(s: Stats): Day[][] { + const perDay = new Map(s.by_day.map((d) => [d.day, d.notes])); + const today = localDay(new Date()); + return calendarWeeks(HEATMAP_WEEKS).map((week) => + week.map((date) => { + const day = localDay(date); + return { + day, + label: dayTitle.format(date), + value: perDay.get(day) ?? 0, + future: day > today, + }; + }), + ); +} + +function weekly(s: Stats) { + const perWeek = new Map(s.by_week.map((w) => [w.week, w.notes])); + return weeksEnding(weekKey(new Date()), COLUMN_WEEKS).map(({ key, monday }) => ({ + key, + label: `${monday.getUTCMonth() + 1}/${monday.getUTCDate()}`, + value: perWeek.get(key) ?? 0, + })); +} + +function percent(part: number, whole: number): string { + return whole === 0 ? "—" : `${Math.round((part / whole) * 100)}%`; +} + +function delta(current: number, previous: number): string { + if (current === previous) { + return ",持平"; + } + return current > previous ? `,多 ${current - previous}` : `,少 ${previous - current}`; +} diff --git a/apps/web/src/pages/StatsPage.tsx b/apps/web/src/pages/StatsPage.tsx deleted file mode 100644 index 183de97..0000000 --- a/apps/web/src/pages/StatsPage.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { q } from "../api/queries"; -import { BarList, Columns } from "../components/charts"; -import { TopicRows } from "../components/TopicRows"; -import { Empty, ErrorBox, Loading, PageHeader, Section, Stat } from "../components/ui"; -import { weekKey, weeksEnding } from "../lib/weeks"; - -const WEEKS = 16; -const TOP_BARS = 12; - -export function StatsPage() { - const stats = useQuery(q.stats()); - if (stats.isPending) { - return <Loading />; - } - if (stats.isError) { - return <ErrorBox error={stats.error} />; - } - const s = stats.data; - const perWeek = new Map(s.by_week.map((w) => [w.week, w.notes])); - const columns = weeksEnding(weekKey(new Date()), WEEKS).map(({ key, monday }) => ({ - key, - label: `${monday.getUTCMonth() + 1}/${monday.getUTCDate()}`, - value: perWeek.get(key) ?? 0, - })); - const undigested = s.top_topics.filter((t) => t.ask_count >= 2 && !t.annotated); - - return ( - <div> - <PageHeader title="统计" /> - <div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> - <Stat label="Note" value={s.notes} /> - <Stat label="Topic" value={s.topics} /> - <Stat label="重问过的 topic" value={s.repeated_topics} hint="问过 2 次及以上" /> - <Stat label="Annotation" value={s.annotations} /> - </div> - <Section title={`每周 distill 的 note(最近 ${WEEKS} 周)`}> - <Columns columns={columns} unit="条" /> - </Section> - <Section title="问得最多的 topic"> - <TopicRows topics={s.top_topics.filter((t) => t.ask_count >= 2)} /> - </Section> - <Section title="重问过、还没写 annotation 的 topic"> - {undigested.length > 0 ? ( - <TopicRows topics={undigested} /> - ) : ( - <Empty>重问过的 topic 都写了 annotation。</Empty> - )} - </Section> - <div className="grid gap-x-10 md:grid-cols-2"> - <Section title="按 tag"> - <BarList - bars={s.tags.slice(0, TOP_BARS).map((t) => ({ - key: t.tag, - label: `#${t.tag}`, - value: t.notes, - }))} - link={(b) => ({ to: "/tags/$tag", params: { tag: b.key } })} - /> - </Section> - <Section title="按项目"> - <BarList - bars={s.by_project.slice(0, TOP_BARS).map((p) => ({ - key: p.project, - label: p.project.split("/").slice(-2).join("/"), - value: p.notes, - }))} - /> - </Section> - </div> - </div> - ); -} diff --git a/apps/web/src/pages/TagsPage.tsx b/apps/web/src/pages/TagsPage.tsx deleted file mode 100644 index 97c197b..0000000 --- a/apps/web/src/pages/TagsPage.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { Link, useParams } from "@tanstack/react-router"; -import { q } from "../api/queries"; -import { BarList } from "../components/charts"; -import { NoteList } from "../components/NoteList"; -import { Empty, ErrorBox, Loading, PageHeader } from "../components/ui"; - -export function TagsPage() { - const tags = useQuery(q.tags()); - if (tags.isPending) { - return <Loading />; - } - if (tags.isError) { - return <ErrorBox error={tags.error} />; - } - return ( - <div> - <PageHeader title="Tags">{tags.data.length} 个 tag,按 note 数排列。</PageHeader> - {tags.data.length === 0 ? ( - <Empty>还没有 tag。</Empty> - ) : ( - <BarList - bars={tags.data.map((t) => ({ key: t.tag, label: `#${t.tag}`, value: t.notes }))} - link={(b) => ({ to: "/tags/$tag", params: { tag: b.key } })} - /> - )} - </div> - ); -} - -export function TagPage() { - const { tag } = useParams({ from: "/tags/$tag" }); - const notes = useQuery(q.notes({ tag, limit: 500 })); - return ( - <div> - <PageHeader eyebrow={<Link to="/tags">Tags</Link>} title={`#${tag}`}> - {notes.data && `${notes.data.length} 条 note`} - </PageHeader> - {notes.isPending ? ( - <Loading /> - ) : notes.isError ? ( - <ErrorBox error={notes.error} /> - ) : ( - <NoteList notes={notes.data} empty="这个 tag 下没有 note。" /> - )} - </div> - ); -} diff --git a/apps/web/src/pages/TopicsPage.tsx b/apps/web/src/pages/TopicsPage.tsx deleted file mode 100644 index 7c0fbda..0000000 --- a/apps/web/src/pages/TopicsPage.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { useNavigate, useSearch } from "@tanstack/react-router"; -import type { TopicCount } from "../api/generated/TopicCount"; -import { q } from "../api/queries"; -import { DuplicateList } from "../components/Duplicates"; -import { TopicRows } from "../components/TopicRows"; -import { cx, ErrorBox, Loading, PageHeader } from "../components/ui"; - -export type TopicView = "asked" | "recent" | "undigested" | "duplicates"; - -const VIEWS: { value: TopicView; label: string }[] = [ - { value: "asked", label: "问得最多" }, - { value: "recent", label: "最近问过" }, - { value: "undigested", label: "重问过、没有 annotation" }, - { value: "duplicates", label: "可能重复" }, -]; - -function arrange(topics: TopicCount[], view: TopicView): TopicCount[] { - switch (view) { - case "asked": - return topics; - case "recent": - return [...topics].sort((a, b) => b.last_asked.localeCompare(a.last_asked)); - case "undigested": - case "duplicates": - return topics.filter((t) => t.ask_count >= 2 && !t.annotated); - } -} - -export function TopicsPage() { - const { view = "asked" } = useSearch({ from: "/topics" }); - const navigate = useNavigate({ from: "/topics" }); - const topics = useQuery(q.topics()); - - return ( - <div> - <PageHeader title="Topics"> - 一个 topic 是一个问题。同一个问题问了几次,就有几条 note。 - </PageHeader> - <div className="mb-5 flex flex-wrap gap-1" role="tablist" aria-label="排序"> - {VIEWS.map((v) => ( - <button - key={v.value} - type="button" - role="tab" - aria-selected={view === v.value} - onClick={() => navigate({ search: { view: v.value }, replace: true })} - className={cx( - "rounded-[1px] px-2.5 py-1 text-sm transition-colors duration-150", - view === v.value - ? "bg-brand-strong text-on-brand" - : "text-ink-muted hover:bg-panel-hover hover:text-ink", - )} - > - {v.label} - </button> - ))} - </div> - {view === "duplicates" ? ( - <> - <p className="mb-4 text-sm text-ink-faint"> - 同一个问题被存成了不同 topic 时,提问次数会被拆散。合并后次数加在一起,note 文件不变。 - </p> - <DuplicateList /> - </> - ) : topics.isPending ? ( - <Loading /> - ) : topics.isError ? ( - <ErrorBox error={topics.error} /> - ) : ( - <TopicRows topics={arrange(topics.data, view)} /> - )} - </div> - ); -} diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 09c6202..c86adbc 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -1,45 +1,29 @@ import { createRootRoute, createRoute, createRouter } from "@tanstack/react-router"; import { Layout } from "./components/Layout"; import { Empty } from "./components/ui"; -import { HomePage } from "./pages/HomePage"; +import { DistillPage } from "./pages/DistillPage"; import { NotePage } from "./pages/NotePage"; -import { NotesPage } from "./pages/NotesPage"; import { ProblemsPage } from "./pages/ProblemsPage"; -import { StatsPage } from "./pages/StatsPage"; -import { TagPage, TagsPage } from "./pages/TagsPage"; +import { ReviewPage } from "./pages/ReviewPage"; import { TopicPage } from "./pages/TopicPage"; -import { TopicsPage, type TopicView } from "./pages/TopicsPage"; const root = createRootRoute({ component: Layout, notFoundComponent: () => <Empty>这个页面不存在。</Empty>, }); -const TOPIC_VIEWS: TopicView[] = ["asked", "recent", "undigested", "duplicates"]; - const routeTree = root.addChildren([ - createRoute({ getParentRoute: () => root, path: "/", component: HomePage }), createRoute({ getParentRoute: () => root, - path: "/notes", - component: NotesPage, - validateSearch: (search: Record<string, unknown>): { q?: string } => ({ - q: typeof search.q === "string" && search.q ? search.q : undefined, + path: "/", + component: DistillPage, + validateSearch: (search: Record<string, unknown>): { tag?: string } => ({ + tag: typeof search.tag === "string" && search.tag ? search.tag : undefined, }), }), + createRoute({ getParentRoute: () => root, path: "/review", component: ReviewPage }), createRoute({ getParentRoute: () => root, path: "/notes/$noteId", component: NotePage }), - createRoute({ - getParentRoute: () => root, - path: "/topics", - component: TopicsPage, - validateSearch: (search: Record<string, unknown>): { view?: TopicView } => ({ - view: TOPIC_VIEWS.find((v) => v === search.view), - }), - }), createRoute({ getParentRoute: () => root, path: "/topics/$topicId", component: TopicPage }), - createRoute({ getParentRoute: () => root, path: "/tags", component: TagsPage }), - createRoute({ getParentRoute: () => root, path: "/tags/$tag", component: TagPage }), - createRoute({ getParentRoute: () => root, path: "/stats", component: StatsPage }), createRoute({ getParentRoute: () => root, path: "/problems", component: ProblemsPage }), ]); diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 8480565..d347ae8 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -109,6 +109,9 @@ font-size: 1rem; line-height: 1.75; } +.prose-note > :first-child { + margin-top: 0; +} .prose-note > * + * { margin-top: 1.25rem; } diff --git a/crates/distill-cli/CHANGELOG.md b/crates/distill-cli/CHANGELOG.md index 81c6562..1bbe677 100644 --- a/crates/distill-cli/CHANGELOG.md +++ b/crates/distill-cli/CHANGELOG.md @@ -16,7 +16,7 @@ - `distill model pull` downloads the embedding model and embeds existing notes; `distill model status` reports it. `distill recall`, `distill_recall` and the web UI use it when installed; `distill_recall` returns `semantic`. `doctor` shows whether it is installed. -- Web API: `GET /api/topics/{id}/similar`, `GET /api/duplicates`, +- Web API: `GET /api/timeline`, `GET /api/topics/{id}/similar`, `GET /api/duplicates`, `POST /api/duplicates/dismiss`. - `distill mcp` starts the web UI in the background before returning note links, unless `ui.autostart` is `false`. `distill_recall` and `distill_search` notes now carry `url`. diff --git a/crates/distill-cli/src/ui/api.rs b/crates/distill-cli/src/ui/api.rs index 54ba081..501876f 100644 --- a/crates/distill-cli/src/ui/api.rs +++ b/crates/distill-cli/src/ui/api.rs @@ -13,7 +13,7 @@ use distill_core::Distill; use distill_core::embed::Embedder; use distill_core::index::{ Conflict, InvalidFile, NoteDetail, NoteHit, RecallResult, SimilarTopic, Stats, TagCount, - TopicCount, TopicDetail, + TimelinePage, TopicCount, TopicDetail, }; use distill_core::ops::Duplicates; use serde::{Deserialize, Serialize}; @@ -52,6 +52,18 @@ pub struct NotesQuery { pub limit: Option<usize>, } +#[derive(Debug, Deserialize, ts_rs::TS)] +#[ts(export)] +pub struct TimelineQuery { + #[ts(optional)] + pub tag: Option<String>, + /// The `next` cursor from the previous page. + #[ts(optional)] + pub before: Option<String>, + #[ts(optional)] + pub limit: Option<usize>, +} + #[derive(Debug, Deserialize, ts_rs::TS)] #[ts(export)] pub struct RecallQuery { @@ -124,6 +136,7 @@ const MAX_LIMIT: usize = 500; pub fn routes() -> Router<AppState> { Router::new() .route("/session", get(session)) + .route("/timeline", get(timeline)) .route("/notes", get(notes)) .route("/notes/{id}", get(note)) .route("/notes/{id}/annotations", post(add_annotation)) @@ -176,6 +189,18 @@ async fn notes( .await } +async fn timeline( + State(s): State<AppState>, + Query(q): Query<TimelineQuery>, +) -> Result<Json<TimelinePage>, ApiError> { + let limit = q.limit.unwrap_or(DEFAULT_LIMIT).min(MAX_LIMIT); + read(&s, move |d| { + d.index + .timeline(q.tag.as_deref(), q.before.as_deref(), limit) + }) + .await +} + async fn note( State(s): State<AppState>, Path(id): Path<String>, diff --git a/crates/distill-core/CHANGELOG.md b/crates/distill-core/CHANGELOG.md index d9a2868..3c31fcd 100644 --- a/crates/distill-core/CHANGELOG.md +++ b/crates/distill-core/CHANGELOG.md @@ -20,6 +20,8 @@ - `schema` feature: JSON Schema for `SaveRequest`, used as the MCP tool schema. - Reads for the web UI: `Index::note_detail` (body, file, topic label, ask count, reopen links), `Index::topic_detail` (timeline, sessions, merged-in topics) and `Index::topics`. +- `Index::timeline`: notes newest first, paged by cursor, optionally by tag, each with its + position in its topic. `Stats` gains `by_day`, `by_agent` and `annotated_repeated_topics`. - Edits for the web UI: `Distill::rename_topic`, `merge_topic`, `update_annotation`, `delete_annotation` and `resolve_conflict`. Settling a conflict moves the other copies to `<data dir>/discarded/<vault id>/` instead of deleting them. diff --git a/crates/distill-core/README.md b/crates/distill-core/README.md index 513c39a..96d3d47 100644 --- a/crates/distill-core/README.md +++ b/crates/distill-core/README.md @@ -24,6 +24,7 @@ the per-device SQLite index derived from it, and the operations that read and wr | `embed::Embedder` | The local embedding model: `pull`, `installed`, `load` (cached per process), `embed` | | `Index::search` | Keyword search, optionally filtered by tag | | `Index::stats` / `tags` | Counts by topic, tag, ISO week and project | +| `Index::timeline` | Notes newest first in cursor pages, optionally by tag | | `Index::note_detail` / `topic_detail` / `topics` | What the web UI's note, topic and topic-list pages show | | `redact::redact` | Secret redaction applied before anything reaches the vault | | `sources` | Hook input and agent detection, injected context, source verification, reopen links, installed-plugin detection | diff --git a/crates/distill-core/src/index/browse.rs b/crates/distill-core/src/index/browse.rs index 328d7b9..0f56b09 100644 --- a/crates/distill-core/src/index/browse.rs +++ b/crates/distill-core/src/index/browse.rs @@ -1,7 +1,7 @@ //! Reads behind the web UI's pages: one note with everything its page shows, one topic with //! its timeline, every topic, and the vault files behind topics and annotations. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use rusqlite::OptionalExtension; use serde::Serialize; @@ -9,7 +9,9 @@ use serde::Serialize; use super::Index; use super::query::{NoteView, TopicCount, note_view, resolve_merge, topic_counts}; use crate::error::Result; +use crate::model::Source; use crate::sources::{Reopen, reopen}; +use crate::tags::resolve_alias; #[derive(Debug, Clone, Serialize, ts_rs::TS)] #[ts(export)] @@ -49,7 +51,92 @@ pub struct TopicDetail { pub notes: Vec<NoteView>, } +/// One note on the timeline: the question and the start of the answer, plus where it sits +/// in its topic. +#[derive(Debug, Clone, Serialize, ts_rs::TS)] +#[ts(export)] +pub struct TimelineItem { + pub id: String, + pub topic_id: String, + pub topic_label: String, + pub title: String, + pub question: String, + pub conclusion: String, + pub tags: Vec<String>, + pub created: String, + pub source: Source, + /// This note is the Nth in its topic, oldest first: "the Nth time you asked". + pub ask_index: usize, + /// Notes in the topic. + pub ask_count: usize, + pub annotations: usize, +} + +#[derive(Debug, Clone, Serialize, ts_rs::TS)] +#[ts(export)] +pub struct TimelinePage { + /// Newest first. + pub items: Vec<TimelineItem>, + /// Pass back as `before` for the next page; absent after the last page. + #[ts(optional)] + pub next: Option<String>, +} + impl Index { + /// Notes newest first, optionally only those with `tag`, in pages of `limit`. `before` + /// is the `next` cursor of the previous page. + pub fn timeline( + &self, + tag: Option<&str>, + before: Option<&str>, + limit: usize, + ) -> Result<TimelinePage> { + let mut rows = self.rows()?; + rows.sort_by(|a, b| { + b.created_utc + .cmp(&a.created_utc) + .then_with(|| b.id.cmp(&a.id)) + }); + let mut position: HashMap<&str, usize> = HashMap::new(); + let mut counts: HashMap<&str, usize> = HashMap::new(); + for row in rows.iter().rev() { + let n = counts.entry(row.topic_id.as_str()).or_default(); + *n += 1; + position.insert(row.id.as_str(), *n); + } + let aliases = self.aliases()?; + let tag = tag.map(|t| resolve_alias(t, &aliases)); + let labels = self.topic_labels()?; + let annotations = self.annotations_by_note()?; + let mut matching = rows + .iter() + .filter(|r| tag.as_ref().is_none_or(|t| r.tags.contains(t))) + .filter(|r| before.is_none_or(|b| cursor(&r.created_utc, &r.id).as_str() < b)); + let chosen: Vec<_> = matching.by_ref().take(limit).collect(); + let next = match (matching.next(), chosen.last()) { + (Some(_), Some(last)) => Some(cursor(&last.created_utc, &last.id)), + _ => None, + }; + let page = chosen + .into_iter() + .map(|row| TimelineItem { + id: row.id.clone(), + topic_id: row.topic_id.clone(), + topic_label: labels.get(&row.topic_id).cloned().unwrap_or_default(), + title: row.title.clone(), + question: row.question.clone(), + conclusion: row.conclusion.clone(), + tags: row.tags.clone(), + created: row.created.clone(), + source: row.source.clone(), + ask_index: position[row.id.as_str()], + ask_count: counts[row.topic_id.as_str()], + annotations: annotations.get(&row.id).map_or(0, Vec::len), + }) + .collect(); + Ok(TimelinePage { items: page, next }) + } + pub fn note_detail(&self, id: &str) -> Result<Option<NoteDetail>> { let Some(note) = self.note(id)? else { return Ok(None); @@ -142,3 +229,8 @@ impl Index { .optional()?) } } + +/// Sorts like the timeline: by UTC creation time, then id. +fn cursor(created_utc: &str, id: &str) -> String { + format!("{created_utc} {id}") +} diff --git a/crates/distill-core/src/index/mod.rs b/crates/distill-core/src/index/mod.rs index a141cc1..5de80d6 100644 --- a/crates/distill-core/src/index/mod.rs +++ b/crates/distill-core/src/index/mod.rs @@ -20,10 +20,10 @@ use rusqlite_migration::{M, Migrations}; use crate::error::{IoContext, Result}; -pub use browse::{NoteDetail, TopicDetail, TopicRef}; +pub use browse::{NoteDetail, TimelineItem, TimelinePage, TopicDetail, TopicRef}; pub use query::{ - AnnotationView, Conflict, InvalidFile, NoteHit, NoteView, ProjectCount, RecallResult, - RecallTopic, Stats, TagCount, TopicCount, WeekCount, + AgentCount, AnnotationView, Conflict, DayCount, InvalidFile, NoteHit, NoteView, ProjectCount, + RecallResult, RecallTopic, Stats, TagCount, TopicCount, WeekCount, }; pub use scan::SyncReport; pub use semantic::{DUPLICATE_MIN, SimilarTopic, TopicPair}; diff --git a/crates/distill-core/src/index/query.rs b/crates/distill-core/src/index/query.rs index 0c948d9..b82790a 100644 --- a/crates/distill-core/src/index/query.rs +++ b/crates/distill-core/src/index/query.rs @@ -90,6 +90,20 @@ pub struct WeekCount { pub notes: usize, } +#[derive(Debug, Clone, Serialize, ts_rs::TS)] +#[ts(export)] +pub struct DayCount { + pub day: String, + pub notes: usize, +} + +#[derive(Debug, Clone, Serialize, ts_rs::TS)] +#[ts(export)] +pub struct AgentCount { + pub agent: Agent, + pub notes: usize, +} + #[derive(Debug, Clone, Serialize, ts_rs::TS)] #[ts(export)] pub struct ProjectCount { @@ -107,7 +121,12 @@ pub struct Stats { pub top_topics: Vec<TopicCount>, pub tags: Vec<TagCount>, pub by_week: Vec<WeekCount>, + /// Notes per local calendar day (`2026-09-13`), days without notes left out. + pub by_day: Vec<DayCount>, pub by_project: Vec<ProjectCount>, + pub by_agent: Vec<AgentCount>, + /// Topics asked twice or more that have at least one annotation. + pub annotated_repeated_topics: usize, pub conflicts: usize, pub invalid_files: usize, } @@ -298,12 +317,25 @@ impl Index { let annotations = self.annotations_by_note()?; let mut top = topic_counts(&rows, &self.topic_labels()?, &annotations); let repeated_topics = top.iter().filter(|t| t.ask_count >= 2).count(); + let annotated_repeated_topics = top + .iter() + .filter(|t| t.ask_count >= 2 && t.annotated) + .count(); let topics = top.len(); top.truncate(TOP_TOPICS); let mut weeks: BTreeMap<String, usize> = BTreeMap::new(); let mut projects: HashMap<String, usize> = HashMap::new(); + let mut days: BTreeMap<&str, usize> = BTreeMap::new(); + let mut agents: BTreeMap<&str, (Agent, usize)> = BTreeMap::new(); for row in &rows { + if let Some(day) = row.created.get(..10) { + *days.entry(day).or_default() += 1; + } + agents + .entry(row.source.agent.as_str()) + .or_insert((row.source.agent, 0)) + .1 += 1; if let Some(week) = iso_week(&row.created) { *weeks.entry(week).or_default() += 1; } @@ -331,7 +363,19 @@ impl Index { .into_iter() .map(|(week, notes)| WeekCount { week, notes }) .collect(), + by_day: days + .into_iter() + .map(|(day, notes)| DayCount { + day: day.to_string(), + notes, + }) + .collect(), by_project, + by_agent: agents + .into_values() + .map(|(agent, notes)| AgentCount { agent, notes }) + .collect(), + annotated_repeated_topics, conflicts: self.conflicts()?.len(), invalid_files: self.invalid_files()?.len(), }) diff --git a/crates/distill-core/tests/browse.rs b/crates/distill-core/tests/browse.rs new file mode 100644 index 0000000..0af70bf --- /dev/null +++ b/crates/distill-core/tests/browse.rs @@ -0,0 +1,46 @@ +#![allow(clippy::unwrap_used)] // Test helpers panic on setup failure by design. + +mod common; + +use common::{open, request, setup}; + +#[test] +fn timeline_pages_newest_first_and_numbers_repeats() { + let s = setup(); + let mut d = open(&s); + let first = d.save(request("new", &[], &["sqlite"])).unwrap(); + let other = d.save(request("new", &[], &["rust"])).unwrap(); + let again = d.save(request(&first.topic_id, &["sqlite"], &[])).unwrap(); + + let page = d.index.timeline(None, None, 2).unwrap(); + let ids: Vec<&str> = page.items.iter().map(|i| i.id.as_str()).collect(); + assert_eq!(ids, [again.note_id.as_str(), other.note_id.as_str()]); + assert_eq!((page.items[0].ask_index, page.items[0].ask_count), (2, 2)); + assert!(page.items[0].question.contains("sqlite-vec")); + + let rest = d.index.timeline(None, page.next.as_deref(), 2).unwrap(); + assert_eq!(rest.items.len(), 1); + assert_eq!(rest.items[0].id, first.note_id); + assert_eq!(rest.items[0].ask_index, 1); + assert!(rest.next.is_none()); + + let tagged = d.index.timeline(Some("rust"), None, 10).unwrap(); + assert_eq!(tagged.items.len(), 1); + assert_eq!(tagged.items[0].id, other.note_id); +} + +#[test] +fn stats_count_days_agents_and_annotated_repeats() { + let s = setup(); + let mut d = open(&s); + let first = d.save(request("new", &[], &["sqlite"])).unwrap(); + d.save(request(&first.topic_id, &["sqlite"], &[])).unwrap(); + let stats = d.index.stats().unwrap(); + assert_eq!(stats.by_day.iter().map(|x| x.notes).sum::<usize>(), 2); + assert_eq!(stats.by_agent.len(), 1); + assert_eq!(stats.by_agent[0].notes, 2); + assert_eq!(stats.annotated_repeated_topics, 0); + + d.annotate(&first.note_id, "懂了").unwrap(); + assert_eq!(d.index.stats().unwrap().annotated_repeated_topics, 1); +} diff --git a/docs/web-ui.md b/docs/web-ui.md index e75ffb3..d29aaf4 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -65,6 +65,7 @@ as `{ "error": "<message>" }` with 400 (invalid input), 401, 403, 404 (unknown i |---|---| | `GET /api/health` | `Health`; no session needed | | `GET /api/session` | `Session`: version, vault path, UI origin, whether the embedding model is installed | +| `GET /api/timeline?tag=&before=&limit=` | `TimelinePage`: notes newest first with question, ask position and annotation count; `next` is the cursor for `before` | | `GET /api/notes?q=&tag=&limit=` | `NoteHit[]`: keyword search, or recent notes when `q` is empty | | `GET /api/notes/{id}` | `NoteDetail` | | `POST /api/notes/{id}/annotations` | Adds an annotation (`AnnotationInput`); returns the updated `NoteDetail` | @@ -77,7 +78,7 @@ as `{ "error": "<message>" }` with 400 (invalid input), 401, 403, 404 (unknown i | `GET /api/duplicates` | `Duplicates`: topic pairs that are probably one question ([recall.md](recall.md)) | | `POST /api/duplicates/dismiss` | Stops suggesting a pair (`PairDismissal`) on this device | | `GET /api/tags` | `TagCount[]` | -| `GET /api/stats` | `Stats` | +| `GET /api/stats` | `Stats`, including notes per day and per agent | | `GET /api/recall?q=` | `RecallResult` | | `GET /api/problems` | `Problems`: sync conflicts and unreadable files | | `POST /api/conflicts/resolve` | Keeps one copy (`ConflictChoice`) and moves the rest to `<data dir>/discarded/` | diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8dfd183..814c71f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -43,9 +43,6 @@ importers: remark-gfm: specifier: 4.0.1 version: 4.0.1 - virtua: - specifier: 0.52.7 - version: 0.52.7(react-dom@19.3.0(react@19.3.0))(react@19.3.0) devDependencies: '@babel/core': specifier: ^8.0.6 @@ -1595,32 +1592,6 @@ packages: vfile@6.0.3: resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} - virtua@0.52.7: - resolution: {integrity: sha512-3xdhgKCbetu7Gez+DJfjmE+1zI7tsCN+07ZpUBhABDyqqT5r+fji6c0vmQCZlxI8o022d/LdIm2E/9T/LE9rPA==} - peerDependencies: - '@angular/common': '>=20.0.0' - '@angular/core': '>=20.0.0' - react: '>=16.14.0' - react-dom: '>=16.14.0' - solid-js: '>=1.0' - svelte: '>=5.0' - vue: '>=3.2' - peerDependenciesMeta: - '@angular/common': - optional: true - '@angular/core': - optional: true - react: - optional: true - react-dom: - optional: true - solid-js: - optional: true - svelte: - optional: true - vue: - optional: true - vite@8.3.1: resolution: {integrity: sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3228,11 +3199,6 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - virtua@0.52.7(react-dom@19.3.0(react@19.3.0))(react@19.3.0): - optionalDependencies: - react: 19.3.0 - react-dom: 19.3.0(react@19.3.0) - vite@8.3.1(@types/node@22.20.4)(jiti@2.7.0): dependencies: lightningcss: 1.33.0 diff --git a/spec/2026-09-13-distill-foundation/flows.md b/spec/2026-09-13-distill-foundation/flows.md index 7fa45d3..e092eca 100644 --- a/spec/2026-09-13-distill-foundation/flows.md +++ b/spec/2026-09-13-distill-foundation/flows.md @@ -64,6 +64,11 @@ | 搜索 | 命令面板,关键词搜 note(D3) | | 统计 | 内容 P3 前定(原 Q13) | +> **2026-09-26 备注。** 实现时按用户要求改了页面结构:侧栏两个 tab,Distill 和 Review,下面列出 +> 全部 tag。Distill 是 note 的时间线(问题加结论前两行,点开看全文,点 tag 筛选),取代首页、 +> tag 页和 note 列表;Review 是统计仪表盘,取代统计页和 topic 列表。note 页、topic 页、冲突页 +> 保留。现状见 `apps/web/README.md`。 + 用户在页面上能改的:标注、处理冲突副本、合并 topic(P3 前定,原 Q11)。改 note 正文用自己的编辑器直接打开 文件,保存后索引自动发现变化(D2)。