Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions docs/architecture/data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@

不同瀏覽器使用完全不同的時間格式來儲存歷史紀錄:

| 瀏覽器 | 原生時間格式 |
|--------|-------------|
| Chrome / Chromium | WebKit epoch — 自 1601-01-01 00:00:00 UTC 起的微秒數 |
| Firefox | Unix epoch 毫秒數 |
| Safari | Mac absolute time — 自 2001-01-01 00:00:00 UTC 起的秒數(浮點數) |
| Google Takeout | ISO 8601 字串(如 `2024-03-15T10:30:00.000Z`) |
| 瀏覽器 | 原生時間格式 |
| ----------------- | ----------------------------------------------------------------- |
| Chrome / Chromium | WebKit epoch — 自 1601-01-01 00:00:00 UTC 起的微秒數 |
| Firefox | Unix epoch 毫秒數 |
| Safari | Mac absolute time — 自 2001-01-01 00:00:00 UTC 起的秒數(浮點數) |
| Google Takeout | ISO 8601 字串(如 `2024-03-15T10:30:00.000Z`) |

**我們的 archive 必須統一所有時間為單一格式。**

Expand Down
38 changes: 19 additions & 19 deletions docs/architecture/tech-stack.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,19 @@

## 技術棧

| 層面 | 選型 | 理由 |
|------|------|------|
| 桌面框架 | Tauri 2 | 跨平台、Rust 核心、輕量級 |
| 核心邏輯 | Rust workspace(vault-core, vault-worker, vault-platform) | 高性能、安全、跨平台 |
| 瀏覽器解析 | `browser-history-parser` — 計劃獨立發布的 Rust crate | 通用的瀏覽器歷史紀錄解析,可供社區使用 |
| 前端 | React 19 + TypeScript + Vite | 現代前端、型別安全 |
| 工具鏈 | Bun | JS 側的包管理與腳本 |
| 數據存儲 | SQLite(可選 SQLCipher 加密) | 本地優先、20 年持久性 |
| 全文搜尋 | SQLite FTS5 | 核心召回能力,不依賴外部服務 |
| 向量 / 語義檢索 | LanceDB sidecar | 嵌入式、Rust 原生、disk-based indexing |
| AI 框架 | rig.rs | Rust 原生的 LLM + Embedding 框架 |
| AI 推理 | 本地推理(Ollama / LM Studio)或雲端 API | 可選、可配置 |
| 審計 | Git(只管理 manifests 和審計工件) | 可追溯性 |
| 層面 | 選型 | 理由 |
| --------------- | ---------------------------------------------------------- | -------------------------------------- |
| 桌面框架 | Tauri 2 | 跨平台、Rust 核心、輕量級 |
| 核心邏輯 | Rust workspace(vault-core, vault-worker, vault-platform) | 高性能、安全、跨平台 |
| 瀏覽器解析 | `browser-history-parser` — 計劃獨立發布的 Rust crate | 通用的瀏覽器歷史紀錄解析,可供社區使用 |
| 前端 | React 19 + TypeScript + Vite | 現代前端、型別安全 |
| 工具鏈 | Bun | JS 側的包管理與腳本 |
| 數據存儲 | SQLite(可選 SQLCipher 加密) | 本地優先、20 年持久性 |
| 全文搜尋 | SQLite FTS5 | 核心召回能力,不依賴外部服務 |
| 向量 / 語義檢索 | LanceDB sidecar | 嵌入式、Rust 原生、disk-based indexing |
| AI 框架 | rig.rs | Rust 原生的 LLM + Embedding 框架 |
| AI 推理 | 本地推理(Ollama / LM Studio)或雲端 API | 可選、可配置 |
| 審計 | Git(只管理 manifests 和審計工件) | 可追溯性 |

## 數據庫分層架構

Expand All @@ -33,12 +33,12 @@

## AI 框架決策:rig.rs

| 維度 | 說明 |
|------|------|
| 為什麼 rig.rs | Rust 原生 LLM/Embedding 框架,與我們的 Rust workspace 自然整合 |
| Embedding | 通過 rig.rs 統一的 provider abstraction 調用,支援 Ollama / OpenAI-compatible / 雲端 API |
| LLM | 同上,用於摘要生成、topic 命名、問答等 Intelligence 功能 |
| 向量存儲 | rig.rs 產生的 embedding 存入 LanceDB sidecar |
| 維度 | 說明 |
| ------------- | ---------------------------------------------------------------------------------------- |
| 為什麼 rig.rs | Rust 原生 LLM/Embedding 框架,與我們的 Rust workspace 自然整合 |
| Embedding | 通過 rig.rs 統一的 provider abstraction 調用,支援 Ollama / OpenAI-compatible / 雲端 API |
| LLM | 同上,用於摘要生成、topic 命名、問答等 Intelligence 功能 |
| 向量存儲 | rig.rs 產生的 embedding 存入 LanceDB sidecar |

## 目標平台

Expand Down
24 changes: 12 additions & 12 deletions docs/design/screens-and-nav.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,18 @@

## 畫面清單

| 畫面 | 核心職責 |
|------|----------|
| **Onboarding / Setup** | 首次啟動引導:發現瀏覽器、選擇 profile、設定存儲、加密選擇 |
| **Dashboard** | 備份狀態總覽、最近 run 摘要、歷史上的今天、定期總結卡片、Job Queue 狀態、快速操作入口 |
| **History Explorer** | 時間軸 + 全文搜尋 + 篩選 + 詳情 + 匯出 |
| **Insights** | 洞察卡片、topic timeline、threads、query ladders、profile facets |
| **AI Assistant** | 自然語言問答介面 |
| **Import** | Takeout 導入 wizard + 瀏覽器直接導入(含 step-by-step UI) |
| **Audit Ledger** | Manifest chain、run 歷史、diff 視圖、schema 變化紀錄 |
| **Security** | 加密設定、keyring、rekey、密碼警告 |
| **Schedule Setup** | 排程預覽 → 手動安裝/自動安裝 → 狀態監控 |
| **Settings** | 通用設定、語言、AI provider 管理、MCP 開關、數據目錄、版本信息 |
| 畫面 | 核心職責 |
| ---------------------- | ------------------------------------------------------------------------------------- |
| **Onboarding / Setup** | 首次啟動引導:發現瀏覽器、選擇 profile、設定存儲、加密選擇 |
| **Dashboard** | 備份狀態總覽、最近 run 摘要、歷史上的今天、定期總結卡片、Job Queue 狀態、快速操作入口 |
| **History Explorer** | 時間軸 + 全文搜尋 + 篩選 + 詳情 + 匯出 |
| **Insights** | 洞察卡片、topic timeline、threads、query ladders、profile facets |
| **AI Assistant** | 自然語言問答介面 |
| **Import** | Takeout 導入 wizard + 瀏覽器直接導入(含 step-by-step UI) |
| **Audit Ledger** | Manifest chain、run 歷史、diff 視圖、schema 變化紀錄 |
| **Security** | 加密設定、keyring、rekey、密碼警告 |
| **Schedule Setup** | 排程預覽 → 手動安裝/自動安裝 → 狀態監控 |
| **Settings** | 通用設定、語言、AI provider 管理、MCP 開關、數據目錄、版本信息 |

---

Expand Down
4 changes: 4 additions & 0 deletions docs/features/archive.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,18 +184,21 @@
### 增強層級

**第一層:立即可用(不需要外部請求)**

- URL 結構解析:domain, subdomain, path tokens, query parameters
- Domain 分類:docs / forum / video / news / social / shopping / code 等
- 搜尋引擎 query 提取(從 URL 參數中解析 `q=`, `search_query=` 等)
- Transition / referrer 信息
- Favicon

**第二層:背景 refetch**

- 訪問 URL 抓取頁面內容,提取 readable text、meta description、OG tags
- 提取頁面語言
- Best-effort,失敗不阻塞

**第三層:基於 URL 的專屬 enrichment 插件**

- 設計為**可擴展的插件架構**:每個插件匹配一組 URL pattern,負責提取特定的結構化信息。
- 插件範例:
- **arXiv 插件**:匹配 `arxiv.org/abs/*`,調用 arXiv API 獲取論文標題、作者、abstract、分類、發表日期
Expand All @@ -209,6 +212,7 @@
- 插件的增強結果存入統一的 enrichment 表,以 JSON 格式保存,欄位隨插件不同而不同。

**第四層:未來擴展 — 瀏覽時即時捕獲**

- 未來可能透過瀏覽器擴充套件在瀏覽時即時抓取頁面內容。
- Schema 預留 `content_source` 欄位標記來源(`plugin`, `refetch`, `realtime_capture`),讓即時捕獲的數據可以取代 refetch 結果。

Expand Down
1 change: 1 addition & 0 deletions docs/features/intelligence.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
- 第三方未來可以貢獻新的洞察插件。

每個洞察模塊定義:

- **名稱和描述**
- **數據依賴**:需要哪些表、哪些 enrichment、是否需要 embedding
- **計算邏輯**:怎麼從原始數據算出洞察結果
Expand Down
17 changes: 10 additions & 7 deletions docs/features/recall.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,14 @@
用戶瀏覽歷史紀錄時,每條記錄默認顯示的信息以及可選顯示的信息,**用戶可以在設定中自定義**。

**預設顯示:**

- Favicon + 頁面標題
- URL(可展開/摺疊長 URL)
- 訪問時間
- 來源瀏覽器 / Profile 標識

**可選顯示(用戶在設定中開關):**

- 訪問次數
- 來源途徑(typed, link, redirect 等)
- Domain 分類標籤
Expand All @@ -69,6 +71,7 @@
### 記錄詳情面板

點擊任一條記錄,展開詳情面板,顯示該條記錄的完整信息:

- 完整 URL
- 頁面標題(所有歷史版本)
- 所有訪問時間(如果同一 URL 被多次訪問)
Expand Down Expand Up @@ -109,13 +112,13 @@

### 典型誤操作場景

| 場景 | 恢復方式 |
|------|----------|
| 導入了格式錯誤的 Takeout 檔案 | 回滾該次 import run |
| 錯誤的 profile 被選中並備份了 | 回滾該次 backup run |
| 同一份數據被重複導入 | 去重機制自動處理;如有異常可回滾 |
| 導入了別人的歷史紀錄 | 回滾該次 import run |
| Schema migration 後發現問題 | Archive DB 自動備份可恢復 |
| 場景 | 恢復方式 |
| ----------------------------- | -------------------------------- |
| 導入了格式錯誤的 Takeout 檔案 | 回滾該次 import run |
| 錯誤的 profile 被選中並備份了 | 回滾該次 backup run |
| 同一份數據被重複導入 | 去重機制自動處理;如有異常可回滾 |
| 導入了別人的歷史紀錄 | 回滾該次 import run |
| Schema migration 後發現問題 | Archive DB 自動備份可恢復 |

### Archive 快照(Safety Net)

Expand Down
151 changes: 133 additions & 18 deletions src-tauri/crates/vault-core/src/archive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ struct BackupManifest {
warnings: Vec<String>,
}

const ARCHIVE_SCHEMA_SQL: &str = include_str!("archive-schema.sql");
const MIGRATION_0001_SQL: &str = include_str!("migrations/0001_initial_archive.sql");
const MIGRATION_0002_SQL: &str = include_str!("migrations/0002_import_recoverability.sql");
const RECENT_RUNS_SQL: &str = "SELECT id, started_at, finished_at, status, manifest_hash, summary_json FROM backup_runs ORDER BY id DESC LIMIT 12";
const LIST_HISTORY_SQL: &str = "SELECT id, profile_id, url, title, visit_time, visit_duration, transition, source_visit_id, app_id FROM visit_events WHERE (?1 IS NULL OR profile_id = ?1) AND (?2 IS NULL OR url LIKE '%' || ?2 || '%' OR IFNULL(title, '') LIKE '%' || ?2 || '%') AND (?3 IS NULL OR url LIKE ?3) ORDER BY visit_time DESC LIMIT ?4";
const INGEST_URLS_SQL: &str = "SELECT id, url, title, visit_count, typed_count, last_visit_time, hidden FROM urls WHERE last_visit_time >= ?1 ORDER BY last_visit_time ASC";
Expand Down Expand Up @@ -490,21 +491,54 @@ pub(crate) fn export_archive_database(
}

pub(crate) fn create_schema(connection: &Connection) -> Result<()> {
connection.execute_batch(ARCHIVE_SCHEMA_SQL)?;
ensure_column(connection, "visit_events", "import_batch_id", "INTEGER")?;
ensure_column(connection, "visit_events", "event_fingerprint", "TEXT")?;
ensure_column(connection, "raw_row_versions", "import_batch_id", "INTEGER")?;
#[rustfmt::skip]
connection.execute("CREATE INDEX IF NOT EXISTS idx_visit_events_import_batch_id ON visit_events(import_batch_id)", [])?;
#[rustfmt::skip]
connection.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_visit_events_profile_event_fingerprint ON visit_events(profile_id, event_fingerprint) WHERE event_fingerprint IS NOT NULL AND event_fingerprint != ''", [])?;
#[rustfmt::skip]
connection.execute("CREATE INDEX IF NOT EXISTS idx_raw_row_versions_import_batch_id ON raw_row_versions(import_batch_id)", [])?;
connection.execute(
"CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL
)",
[],
)?;
apply_migration(connection, 1, "initial_archive_schema", || {
connection.execute_batch(MIGRATION_0001_SQL)?;
Ok(())
})?;
apply_migration(connection, 2, "import_recoverability_columns", || {
ensure_column(connection, "visit_events", "import_batch_id", "INTEGER")?;
ensure_column(connection, "visit_events", "event_fingerprint", "TEXT")?;
ensure_column(connection, "raw_row_versions", "import_batch_id", "INTEGER")?;
connection.execute_batch(MIGRATION_0002_SQL)?;
Ok(())
})?;
ensure_ai_schema(connection)?;
ensure_insight_schema(connection)?;
Ok(())
}

fn apply_migration<F>(connection: &Connection, version: i64, name: &str, migration: F) -> Result<()>
where
F: FnOnce() -> Result<()>,
{
let already_applied = connection
.query_row(
"SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = ?1)",
params![version],
|row| row.get::<_, i64>(0),
)
.optional()?
.unwrap_or(0)
== 1;
if already_applied {
return Ok(());
}
migration()?;
connection.execute(
"INSERT INTO schema_migrations (version, name, applied_at) VALUES (?1, ?2, ?3)",
params![version, name, now_rfc3339()],
)?;
Ok(())
}

fn ensure_column(
connection: &Connection,
table_name: &str,
Expand Down Expand Up @@ -1562,13 +1596,12 @@ mod tests {
#[test]
fn schema_migration_helpers_and_text_renderers_are_stable() {
let connection = Connection::open_in_memory().expect("db");
connection
.execute("CREATE TABLE visit_events (id INTEGER PRIMARY KEY)", [])
.expect("create visit_events");
ensure_column(&connection, "visit_events", "import_batch_id", "INTEGER")
.expect("add column");
ensure_column(&connection, "visit_events", "import_batch_id", "INTEGER")
.expect("idempotent");
create_schema(&connection).expect("create schema");
create_schema(&connection).expect("idempotent");
let applied_versions = connection
.query_row("SELECT COUNT(*) FROM schema_migrations", [], |row| row.get::<_, i64>(0))
.expect("load migration count");
assert_eq!(applied_versions, 2);

let results = HistoryQueryResponse {
total: 1,
Expand Down Expand Up @@ -1598,6 +1631,88 @@ mod tests {
}));
}

#[test]
fn create_schema_upgrades_legacy_archives_without_baseline_stamping() {
let connection = Connection::open_in_memory().expect("db");
connection
.execute_batch(
"
CREATE TABLE profiles (
profile_id TEXT PRIMARY KEY,
profile_name TEXT NOT NULL,
user_name TEXT,
profile_path TEXT NOT NULL,
chrome_version TEXT,
updated_at TEXT NOT NULL
);
CREATE TABLE visit_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
profile_id TEXT NOT NULL,
source_visit_id INTEGER NOT NULL,
source_url_id INTEGER NOT NULL,
url TEXT NOT NULL,
title TEXT,
visit_time INTEGER NOT NULL,
payload_hash TEXT NOT NULL,
recorded_at TEXT NOT NULL,
UNIQUE(profile_id, source_visit_id)
);
CREATE TABLE raw_row_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER NOT NULL,
profile_id TEXT NOT NULL,
source_kind TEXT NOT NULL,
table_name TEXT NOT NULL,
source_pk TEXT NOT NULL,
payload_hash TEXT NOT NULL,
payload_json TEXT NOT NULL,
schema_hash TEXT NOT NULL,
recorded_at TEXT NOT NULL
);
",
)
.expect("legacy schema");

create_schema(&connection).expect("upgrade legacy schema");

let migration_names = {
let mut statement = connection
.prepare("SELECT name FROM schema_migrations ORDER BY version ASC")
.expect("prepare migration list");
statement
.query_map([], |row| row.get::<_, String>(0))
.expect("query migrations")
.collect::<rusqlite::Result<Vec<_>>>()
.expect("collect migrations")
};
assert_eq!(
migration_names,
vec!["initial_archive_schema".to_string(), "import_recoverability_columns".to_string()]
);

let visit_has_import_batch = connection
.prepare("PRAGMA table_info(visit_events)")
.expect("visit info")
.query_map([], |row| row.get::<_, String>(1))
.expect("visit columns")
.collect::<rusqlite::Result<Vec<_>>>()
.expect("collect visit columns")
.into_iter()
.any(|column| column == "import_batch_id");
assert!(visit_has_import_batch);

let raw_row_has_import_batch = connection
.prepare("PRAGMA table_info(raw_row_versions)")
.expect("raw_row info")
.query_map([], |row| row.get::<_, String>(1))
.expect("raw_row columns")
.collect::<rusqlite::Result<Vec<_>>>()
.expect("collect raw_row columns")
.into_iter()
.any(|column| column == "import_batch_id");
assert!(raw_row_has_import_batch);
}

#[test]
fn watermark_checkpoint_and_snapshot_helpers_cover_edge_cases() {
let dir = tempdir().expect("tempdir");
Expand Down
Loading
Loading