From 3d1bc6a6a43eaf61af77241ac05d90672783ca05 Mon Sep 17 00:00:00 2001 From: KuaaMU Date: Sat, 24 Oct 2026 21:54:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(recall):=20=E6=9C=AC=E5=9C=B0=E5=9B=9E?= =?UTF-8?q?=E5=A3=B0=E8=AE=B0=E5=BF=86=E4=B8=8E=20Wayland=20=E4=B8=BB?= =?UTF-8?q?=E9=80=89=E5=8C=BA=E8=AF=BB=E5=8F=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 glint-recall(SQLite + FTS5):记录完成轮次,划词时返回一条旧结论 - HUD 在宿主徽标下渲染单行回声;未命中不渲染,也没有空状态 - 旧结论以 数据块只进入本次请求,不写入对话与历史 - 门禁:同线索排除;双方 doc_key 都存在且不同才免 24 小时年龄门槛 - Wayland 会话优先读 PRIMARY,修正原生 Wayland 窗口读不到选区的问题 - 删除会话时同步清理回声;清空历史时整库删除 - 单测 14 条(只走公开 API)、契约测试、CI 三平台矩阵 Co-Authored-By: Claude Code --- .github/workflows/ci.yml | 5 +- Cargo.lock | 92 ++ Cargo.toml | 7 +- crates/glint-recall/Cargo.toml | 18 + crates/glint-recall/src/lib.rs | 846 ++++++++++++++++++ ...012-linux-native-companion-architecture.md | 1 + index.html | 3 + package.json | 2 +- scripts/check.sh | 6 + src-tauri/Cargo.toml | 1 + src-tauri/src/lib.rs | 152 +++- src/main.ts | 122 ++- src/recall.test.ts | 99 ++ src/recall.ts | 127 +++ src/styles.css | 16 + tests/tier1_features.test.mjs | 49 +- 16 files changed, 1532 insertions(+), 14 deletions(-) create mode 100644 crates/glint-recall/Cargo.toml create mode 100644 crates/glint-recall/src/lib.rs create mode 100644 src/recall.test.ts create mode 100644 src/recall.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad0e98b..0b114b6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,7 +53,9 @@ jobs: - name: Production build run: pnpm build - # glint-core has no GUI dependencies, so it can be tested everywhere. + # glint-core and glint-recall have no GUI dependencies, so they can be + # tested everywhere. glint-recall compiles bundled SQLite, which is the point + # of running it on all three platforms. core: name: Core (${{ matrix.os }}) runs-on: ${{ matrix.os }} @@ -66,6 +68,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - run: cargo test -p glint-core + - run: cargo test -p glint-recall # The Tauri shell is where platform-specific code actually breaks. Linux is # the mainline; Windows/macOS are checked but not yet shipped. diff --git a/Cargo.lock b/Cargo.lock index 547811d..733d9cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1039,6 +1039,18 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.5.0" @@ -1486,6 +1498,7 @@ dependencies = [ "base64 0.22.1", "futures-util", "glint-core", + "glint-recall", "image", "reqwest 0.12.28", "serde", @@ -1528,6 +1541,16 @@ dependencies = [ "zbus", ] +[[package]] +name = "glint-recall" +version = "0.1.0" +dependencies = [ + "rusqlite", + "serde", + "tempfile", + "thiserror 2.0.21", +] + [[package]] name = "glob" version = "0.3.4" @@ -1642,11 +1665,32 @@ dependencies = [ "foldhash 0.1.5", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" +dependencies = [ + "hashbrown 0.17.1", +] [[package]] name = "heck" @@ -2242,6 +2286,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -3265,6 +3320,31 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.21", +] + +[[package]] +name = "rusqlite" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +dependencies = [ + "bitflags 2.13.2", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + [[package]] name = "rustc-hash" version = "2.1.3" @@ -3751,6 +3831,18 @@ dependencies = [ "system-deps", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index 4b28f4c..5af7e83 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,11 @@ [workspace] resolver = "2" -members = ["crates/glint-core", "crates/glint-cli", "src-tauri"] +members = [ + "crates/glint-core", + "crates/glint-cli", + "crates/glint-recall", + "src-tauri", +] # Keep the default check fast: the Tauri shell needs GTK/WebKit development # headers, while the core crate must stay buildable on a bare Rust toolchain. default-members = ["crates/glint-core"] diff --git a/crates/glint-recall/Cargo.toml b/crates/glint-recall/Cargo.toml new file mode 100644 index 0000000..33cfbc9 --- /dev/null +++ b/crates/glint-recall/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "glint-recall" +version = "0.1.0" +description = "Glint Recall - local turn memory and selection-time echo" +authors = ["glint-dev"] +edition = "2024" + +[lib] +name = "glint_recall" +path = "src/lib.rs" + +[dependencies] +rusqlite = { version = "0.40", features = ["bundled"] } +serde = { version = "1", features = ["derive"] } +thiserror = "2" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/glint-recall/src/lib.rs b/crates/glint-recall/src/lib.rs new file mode 100644 index 0000000..f87a69b --- /dev/null +++ b/crates/glint-recall/src/lib.rs @@ -0,0 +1,846 @@ +//! Local turn memory for Glint. +//! +//! The "echo" feature answers one question at selection time: has the user +//! asked about this exact concept before, outside the current thread? The +//! answer lives in a single SQLite file on the user's machine. Nothing in this +//! crate touches the network. +//! +//! ## What is stored +//! +//! Only completed turns: the normalized anchor (the concept the user asked +//! about), the conclusion sentence, the app it came from, an optional +//! `doc_key` digest, and a timestamp. Window titles never reach this crate; +//! callers pass the existing `doc_key` from `glint-core`, not the title. +//! +//! ## Retrieval contract +//! +//! `find_echo` runs inside a selection turn, so it has to stay cheap. An exact +//! anchor lookup uses an index; the fallback uses FTS5. Neither path scans the +//! table. A miss returns `None`, and callers must render nothing: the HUD has +//! no empty state for this feature. + +use std::path::{Path, PathBuf}; + +use rusqlite::{Connection, OptionalExtension, params}; +use serde::{Deserialize, Serialize}; + +/// A completed turn worth remembering. +/// +/// The caller only records turns that finished successfully. Errors, cancelled +/// turns and turns with no usable conclusion must not be recorded: a conclusion +/// that was cut off mid-stream reads as a fact the model never asserted. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TurnRecord { + /// Conversation the turn belongs to. Echo never matches across a thread + /// boundary, so a follow-up never re-surfaces its own context. + pub thread_id: String, + /// Foreground application class, e.g. `code` or `firefox`. + pub app_class: String, + /// Document digest from `glint_core::host`, never the raw window title. + pub doc_key: Option, + /// The concept the user selected and asked about. + pub anchor: String, + /// The conclusion sentence. Truncated to [`MAX_CONCLUSION_CHARS`]. + pub conclusion: String, + /// Optional follow-up the user typed in the same thread. + pub follow_up: Option, + pub completed_at_ms: i64, +} + +/// Where the current selection happened. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EchoContext { + pub current_thread_id: String, + pub current_doc_key: Option, + pub now_ms: i64, + /// A previous turn about the same document only counts once it is at least + /// this old. Re-asking the same question a minute later is not an echo. + pub min_age_ms: i64, +} + +/// One prior answer, ready for the HUD and the prompt budget. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Echo { + pub turn_id: i64, + pub conclusion: String, + pub app_class: String, + pub age_ms: i64, + pub thread_id: String, + /// The stored anchor, which may be longer than what the user selected. + pub matched_anchor: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum RecallError { + #[error("recall database: {0}")] + Database(#[from] rusqlite::Error), + #[error("recall io: {0}")] + Io(#[from] std::io::Error), +} + +/// Keep the stored conclusion short: it travels in a prompt block with a hard +/// token budget, and the HUD renders one line. +pub const MAX_CONCLUSION_CHARS: usize = 120; + +const FTS_CANDIDATE_LIMIT: i64 = 50; + +const SCHEMA: &str = r#" +PRAGMA journal_mode = WAL; +PRAGMA synchronous = NORMAL; +PRAGMA foreign_keys = ON; +PRAGMA user_version = 1; + +CREATE TABLE IF NOT EXISTS turns ( + id INTEGER PRIMARY KEY, + thread_id TEXT NOT NULL, + app_class TEXT NOT NULL, + doc_key TEXT, + anchor TEXT NOT NULL, + anchor_key TEXT NOT NULL, + anchor_search TEXT NOT NULL, + conclusion TEXT NOT NULL, + conclusion_search TEXT NOT NULL, + follow_up TEXT, + completed_at_ms INTEGER NOT NULL, + UNIQUE (thread_id, anchor_key) +); + +CREATE INDEX IF NOT EXISTS turns_anchor_key ON turns(anchor_key, completed_at_ms DESC); +CREATE INDEX IF NOT EXISTS turns_completed ON turns(completed_at_ms DESC); + +CREATE VIRTUAL TABLE IF NOT EXISTS turns_fts USING fts5( + anchor_search, + conclusion_search, + content = 'turns', + content_rowid = 'id', + tokenize = 'unicode61 remove_diacritics 2' +); + +CREATE TRIGGER IF NOT EXISTS turns_ai AFTER INSERT ON turns BEGIN + INSERT INTO turns_fts(rowid, anchor_search, conclusion_search) + VALUES (new.id, new.anchor_search, new.conclusion_search); +END; + +CREATE TRIGGER IF NOT EXISTS turns_ad AFTER DELETE ON turns BEGIN + INSERT INTO turns_fts(turns_fts, rowid, anchor_search, conclusion_search) + VALUES ('delete', old.id, old.anchor_search, old.conclusion_search); +END; + +CREATE TRIGGER IF NOT EXISTS turns_au AFTER UPDATE ON turns BEGIN + INSERT INTO turns_fts(turns_fts, rowid, anchor_search, conclusion_search) + VALUES ('delete', old.id, old.anchor_search, old.conclusion_search); + INSERT INTO turns_fts(rowid, anchor_search, conclusion_search) + VALUES (new.id, new.anchor_search, new.conclusion_search); +END; +"#; + +pub struct RecallStore { + conn: Connection, +} + +impl RecallStore { + /// Open (or create) the store at `path`. Parent directories are created. + pub fn open(path: &Path) -> Result { + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + std::fs::create_dir_all(parent)?; + } + let conn = Connection::open(path)?; + conn.execute_batch(SCHEMA)?; + Ok(Self { conn }) + } + + /// Record one completed turn. Re-asking the same anchor in the same thread + /// updates that row instead of adding a duplicate. + pub fn record_turn(&mut self, turn: TurnRecord) -> Result<(), RecallError> { + let anchor_key = normalize_anchor(&turn.anchor); + if anchor_key.is_empty() { + return Ok(()); + } + + let conclusion = truncate_chars(turn.conclusion.trim(), MAX_CONCLUSION_CHARS); + if conclusion.is_empty() { + return Ok(()); + } + + let follow_up = turn + .follow_up + .as_deref() + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(|text| truncate_chars(text, MAX_CONCLUSION_CHARS)); + + self.conn.execute( + "INSERT INTO turns ( + thread_id, app_class, doc_key, anchor, anchor_key, + anchor_search, conclusion, conclusion_search, follow_up, + completed_at_ms + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) + ON CONFLICT(thread_id, anchor_key) DO UPDATE SET + app_class = excluded.app_class, + doc_key = excluded.doc_key, + anchor = excluded.anchor, + anchor_search = excluded.anchor_search, + conclusion = excluded.conclusion, + conclusion_search = excluded.conclusion_search, + follow_up = excluded.follow_up, + completed_at_ms = excluded.completed_at_ms", + params![ + turn.thread_id, + turn.app_class, + turn.doc_key.as_deref(), + turn.anchor.trim(), + anchor_key, + expand_for_search(&anchor_key), + conclusion, + expand_for_search(&conclusion), + follow_up.as_deref(), + turn.completed_at_ms, + ], + )?; + Ok(()) + } + + /// Find the most recent prior answer about `query`, if one qualifies. + /// + /// A candidate is skipped when it belongs to the current thread, or when it + /// comes from the same document and is younger than `ctx.min_age_ms`. + pub fn find_echo(&self, query: &str, ctx: &EchoContext) -> Result, RecallError> { + let anchor_key = normalize_anchor(query); + if anchor_key.is_empty() { + return Ok(None); + } + + let cutoff = ctx.now_ms.saturating_sub(ctx.min_age_ms.max(0)); + + let exact = { + let mut stmt = self.conn.prepare_cached( + "SELECT id, thread_id, app_class, anchor, anchor_key, conclusion, completed_at_ms + FROM turns + WHERE anchor_key = ?1 + AND thread_id <> ?2 + AND ( + completed_at_ms <= ?4 + OR (doc_key IS NOT ?3 AND doc_key IS NOT NULL AND ?3 IS NOT NULL) + ) + ORDER BY completed_at_ms DESC + LIMIT 1", + )?; + stmt.query_row( + params![ + anchor_key, + ctx.current_thread_id, + ctx.current_doc_key.as_deref(), + cutoff, + ], + RawEcho::from_row, + ) + .optional()? + }; + + if let Some(raw) = exact { + return Ok(Some(raw.into_echo(ctx.now_ms))); + } + + let Some(fts_query) = fts_match_query(&anchor_key) else { + return Ok(None); + }; + + let mut stmt = self.conn.prepare_cached( + "SELECT t.id, t.thread_id, t.app_class, t.anchor, t.anchor_key, + t.conclusion, t.completed_at_ms + FROM turns_fts f + JOIN turns t ON t.id = f.rowid + WHERE turns_fts MATCH ?1 + AND t.thread_id <> ?2 + AND ( + t.completed_at_ms <= ?4 + OR (t.doc_key IS NOT ?3 AND t.doc_key IS NOT NULL AND ?3 IS NOT NULL) + ) + ORDER BY t.completed_at_ms DESC + LIMIT ?5", + )?; + + let mut rows = stmt.query(params![ + fts_query, + ctx.current_thread_id, + ctx.current_doc_key.as_deref(), + cutoff, + FTS_CANDIDATE_LIMIT, + ])?; + + while let Some(row) = rows.next()? { + let raw = RawEcho::from_row(row)?; + if anchors_are_compatible(&anchor_key, &raw.anchor_key) { + return Ok(Some(raw.into_echo(ctx.now_ms))); + } + } + + Ok(None) + } + + /// Forget every turn recorded for one thread. + /// + /// Deleting a conversation from the history panel must also remove what it + /// left in the echo store; otherwise a deleted answer can still resurface + /// next time the user selects the same concept. + pub fn forget_thread(&mut self, thread_id: &str) -> Result { + let removed = self + .conn + .execute("DELETE FROM turns WHERE thread_id = ?1", params![thread_id])?; + Ok(removed) + } + + /// Remove the database and its WAL sidecars. Missing files are not an + /// error: wiping an index that was never created is a no-op. + pub fn wipe(path: &Path) -> Result<(), RecallError> { + for target in [ + path.to_path_buf(), + with_suffix(path, "-wal"), + with_suffix(path, "-shm"), + ] { + match std::fs::remove_file(&target) { + Ok(()) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(err.into()), + } + } + Ok(()) + } +} + +struct RawEcho { + id: i64, + thread_id: String, + app_class: String, + anchor: String, + anchor_key: String, + conclusion: String, + completed_at_ms: i64, +} + +impl RawEcho { + fn from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(Self { + id: row.get(0)?, + thread_id: row.get(1)?, + app_class: row.get(2)?, + anchor: row.get(3)?, + anchor_key: row.get(4)?, + conclusion: row.get(5)?, + completed_at_ms: row.get(6)?, + }) + } + + fn into_echo(self, now_ms: i64) -> Echo { + Echo { + turn_id: self.id, + conclusion: self.conclusion, + app_class: self.app_class, + age_ms: now_ms.saturating_sub(self.completed_at_ms), + thread_id: self.thread_id, + matched_anchor: self.anchor, + } + } +} + +/// Normalize an anchor for exact matching. +/// +/// Deliberately conservative: Unicode NFKC and full-width folding are not done +/// yet, because they need a Unicode table this crate does not carry. ASCII is +/// lowercased, internal whitespace is collapsed, and sentence punctuation is +/// dropped from the edges so a selection ending in a comma still matches. +/// Identifier punctuation (`_`, `-`, `:`, `.`, `<`, `>`) is preserved so +/// `Arc>` does not collapse into a different concept. +fn normalize_anchor(input: &str) -> String { + let mut out = String::with_capacity(input.len()); + let mut pending_space = false; + + for ch in input.trim().chars() { + if ch.is_whitespace() { + pending_space = !out.is_empty(); + continue; + } + if pending_space { + out.push(' '); + pending_space = false; + } + out.extend(ch.to_lowercase()); + } + + out.trim_matches(is_edge_punctuation).trim().to_string() +} + +fn is_edge_punctuation(ch: char) -> bool { + matches!( + ch, + '.' | ',' + | ';' + | '!' + | '?' + | '。' + | ',' + | ';' + | '!' + | '?' + | '、' + | '"' + | '\'' + | '“' + | '”' + | '‘' + | '’' + | '(' + | ')' + | '(' + | ')' + | '[' + | ']' + | '【' + | '】' + | '《' + | '》' + | '「' + | '」' + | '『' + | '』' + ) +} + +/// Expand text into FTS5 tokens. +/// +/// `unicode61` treats a run of Chinese characters as a single token, so a query +/// for `向量` would never match a stored `向量检索`. Overlapping bigrams make +/// every two-character window searchable. Latin and digit runs stay whole and +/// are lowercased. +fn expand_for_search(input: &str) -> String { + let mut out = String::with_capacity(input.len() * 2); + let mut run: Vec = Vec::new(); + let mut word = String::new(); + + for ch in input.chars() { + if is_cjk(ch) { + flush_word(&mut out, &mut word); + run.push(ch); + } else if ch.is_alphanumeric() || ch == '_' { + flush_cjk(&mut out, &mut run); + word.extend(ch.to_lowercase()); + } else { + flush_cjk(&mut out, &mut run); + flush_word(&mut out, &mut word); + } + } + + flush_cjk(&mut out, &mut run); + flush_word(&mut out, &mut word); + out +} + +fn flush_cjk(out: &mut String, run: &mut Vec) { + if run.is_empty() { + return; + } + if run.len() == 1 { + push_token(out, &run[0].to_string()); + } else { + for pair in run.windows(2) { + let token: String = pair.iter().collect(); + push_token(out, &token); + } + } + run.clear(); +} + +fn flush_word(out: &mut String, word: &mut String) { + if !word.is_empty() { + push_token(out, word); + word.clear(); + } +} + +fn push_token(out: &mut String, token: &str) { + if token.is_empty() { + return; + } + if !out.is_empty() { + out.push(' '); + } + out.push_str(token); +} + +fn is_cjk(ch: char) -> bool { + matches!( + ch as u32, + 0x3400..=0x4DBF // CJK Unified Ideographs Extension A + | 0x4E00..=0x9FFF // CJK Unified Ideographs + | 0xF900..=0xFAFF // CJK Compatibility Ideographs + | 0x3040..=0x30FF // Hiragana and Katakana + | 0xAC00..=0xD7AF // Hangul syllables + | 0x20000..=0x2FA1F // CJK Extension B and beyond + ) +} + +/// Build an FTS5 MATCH expression from the expanded anchor. +/// +/// Every token is quoted and the terms are joined by whitespace, which FTS5 +/// reads as an implicit AND. The bigrams of a query must all be present, which +/// is what keeps a short query from matching a long, unrelated anchor. +fn fts_match_query(anchor_key: &str) -> Option { + let expanded = expand_for_search(anchor_key); + let terms: Vec = expanded + .split_whitespace() + .filter(|term| !term.is_empty()) + .map(|term| format!("\"{}\"", term.replace('"', ""))) + .collect(); + + if terms.is_empty() { + None + } else { + Some(terms.join(" ")) + } +} + +/// The FTS fallback accepts a candidate only when one anchor contains the +/// other and the two are within a factor of two in length. Without the ratio +/// check, a query for `cache` would echo every turn that mentioned caching. +fn anchors_are_compatible(query: &str, candidate: &str) -> bool { + if query.is_empty() || candidate.is_empty() { + return false; + } + + let (short, long) = if query.chars().count() <= candidate.chars().count() { + (query, candidate) + } else { + (candidate, query) + }; + + if short.chars().count() < 2 || !long.contains(short) { + return false; + } + + let ratio = short.chars().count() as f32 / long.chars().count() as f32; + ratio >= 0.5 +} + +fn truncate_chars(input: &str, max: usize) -> String { + input + .chars() + .take(max) + .collect::() + .trim() + .to_string() +} + +fn with_suffix(path: &Path, suffix: &str) -> PathBuf { + let mut raw = path.as_os_str().to_owned(); + raw.push(suffix); + PathBuf::from(raw) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + const HOUR: i64 = 3_600_000; + const DAY: i64 = 24 * HOUR; + const NOW: i64 = 1_000 * DAY; + + fn store() -> (TempDir, RecallStore) { + let dir = tempfile::tempdir().expect("temp dir"); + let store = RecallStore::open(&dir.path().join("recall.sqlite3")).expect("open store"); + (dir, store) + } + + fn turn(thread: &str, app: &str, doc: Option<&str>, anchor: &str, at: i64) -> TurnRecord { + TurnRecord { + thread_id: thread.to_string(), + app_class: app.to_string(), + doc_key: doc.map(str::to_string), + anchor: anchor.to_string(), + conclusion: format!("{anchor} 的结论"), + follow_up: None, + completed_at_ms: at, + } + } + + fn ctx(thread: &str, doc: Option<&str>) -> EchoContext { + EchoContext { + current_thread_id: thread.to_string(), + current_doc_key: doc.map(str::to_string), + now_ms: NOW, + min_age_ms: DAY, + } + } + + #[test] + fn empty_store_has_no_echo() { + let (_dir, store) = store(); + let echo = store.find_echo("cache", &ctx("new", Some("doc-b"))); + assert_eq!(echo.expect("query"), None); + } + + #[test] + fn exact_anchor_across_documents_matches() { + let (_dir, mut store) = store(); + store + .record_turn(turn( + "old-thread", + "code", + Some("doc-a"), + "cache invalidation", + NOW - HOUR, + )) + .expect("record"); + + let echo = store + .find_echo("cache invalidation", &ctx("new-thread", Some("doc-b"))) + .expect("query") + .expect("echo"); + + assert_eq!(echo.app_class, "code"); + assert_eq!(echo.thread_id, "old-thread"); + assert_eq!(echo.age_ms, HOUR); + } + + #[test] + fn same_thread_never_matches() { + let (_dir, mut store) = store(); + store + .record_turn(turn( + "thread-1", + "code", + Some("doc-a"), + "borrow checker", + NOW - DAY, + )) + .expect("record"); + + let echo = store + .find_echo("borrow checker", &ctx("thread-1", Some("doc-b"))) + .expect("query"); + assert_eq!(echo, None); + } + + #[test] + fn same_document_within_window_is_skipped() { + let (_dir, mut store) = store(); + store + .record_turn(turn("old", "code", Some("doc-a"), "page table", NOW - HOUR)) + .expect("record"); + + let echo = store + .find_echo("page table", &ctx("new", Some("doc-a"))) + .expect("query"); + assert_eq!(echo, None); + } + + #[test] + fn same_document_after_window_matches() { + let (_dir, mut store) = store(); + store + .record_turn(turn( + "old", + "code", + Some("doc-a"), + "page table", + NOW - 2 * DAY, + )) + .expect("record"); + + let echo = store + .find_echo("page table", &ctx("new", Some("doc-a"))) + .expect("query"); + assert!(echo.is_some(), "a two-day-old turn should qualify"); + } + + #[test] + fn unknown_document_still_requires_age() { + let (_dir, mut store) = store(); + store + .record_turn(turn("old", "code", None, "page table", NOW - HOUR)) + .expect("record"); + + // No stored doc_key: it cannot be proven to be a different document, so + // a one-hour-old turn is too recent to echo. + let recent = store + .find_echo("page table", &ctx("new", Some("doc-b"))) + .expect("query"); + assert_eq!(recent, None); + + let later = store + .find_echo( + "page table", + &EchoContext { + current_thread_id: "new".to_string(), + current_doc_key: Some("doc-b".to_string()), + now_ms: NOW + 2 * DAY, + min_age_ms: DAY, + }, + ) + .expect("query"); + assert!(later.is_some(), "an old turn still qualifies"); + } + + #[test] + fn unknown_current_document_requires_age() { + let (_dir, mut store) = store(); + store + .record_turn(turn("old", "code", Some("doc-a"), "page table", NOW - HOUR)) + .expect("record"); + + // The current document is unknown, which is not evidence that this is a + // different one. + let recent = store + .find_echo("page table", &ctx("new", None)) + .expect("query"); + assert_eq!(recent, None); + } + + #[test] + fn chinese_bigram_recall() { + let (_dir, mut store) = store(); + store + .record_turn(turn( + "old", + "firefox", + Some("doc-a"), + "向量检索的代价", + NOW - DAY, + )) + .expect("record"); + + let echo = store + .find_echo("向量检索", &ctx("new", Some("doc-b"))) + .expect("query") + .expect("echo should match through bigrams"); + + assert_eq!(echo.matched_anchor, "向量检索的代价"); + } + + #[test] + fn short_word_does_not_match_unrelated_anchor() { + let (_dir, mut store) = store(); + store + .record_turn(turn( + "old", + "code", + Some("doc-a"), + "cache invalidation is hard", + NOW - DAY, + )) + .expect("record"); + + let echo = store + .find_echo("cache", &ctx("new", Some("doc-b"))) + .expect("query"); + assert_eq!( + echo, None, + "a five-character query must not echo a long unrelated anchor" + ); + } + + #[test] + fn reasking_updates_the_remembered_conclusion() { + let (_dir, mut store) = store(); + for (at, conclusion) in [(NOW - 2 * DAY, "first"), (NOW - DAY, "second")] { + store + .record_turn(TurnRecord { + conclusion: conclusion.to_string(), + ..turn("thread-1", "code", Some("doc-a"), "tlb", at) + }) + .expect("record"); + } + + // Re-asking in one thread leaves one memory, and it is the latest one. + let echo = store + .find_echo("tlb", &ctx("thread-2", Some("doc-b"))) + .expect("query") + .expect("echo"); + assert_eq!(echo.conclusion, "second"); + } + + #[test] + fn forget_thread_removes_only_that_thread() { + let (_dir, mut store) = store(); + store + .record_turn(turn( + "thread-a", + "code", + Some("doc-a"), + "borrow checker", + NOW - HOUR, + )) + .expect("record a"); + store + .record_turn(turn( + "thread-b", + "code", + Some("doc-b"), + "borrow checker", + NOW - DAY, + )) + .expect("record b"); + + assert_eq!(store.forget_thread("thread-a").expect("forget"), 1); + + // Thread A was the most recent; if it were still there it would win. + let echo = store + .find_echo("borrow checker", &ctx("thread-new", Some("doc-c"))) + .expect("query") + .expect("thread b is still remembered"); + assert_eq!(echo.thread_id, "thread-b"); + } + + #[test] + fn wipe_removes_database() { + let dir = tempfile::tempdir().expect("temp dir"); + let path = dir.path().join("recall.sqlite3"); + + { + let mut store = RecallStore::open(&path).expect("open"); + store + .record_turn(turn("thread", "code", None, "witness", NOW)) + .expect("record"); + } + + assert!(path.exists()); + RecallStore::wipe(&path).expect("wipe"); + assert!(!path.exists()); + } + + #[test] + fn identifiers_match_despite_edge_punctuation_and_case() { + let (_dir, mut store) = store(); + store + .record_turn(turn( + "old", + "code", + Some("doc-a"), + "Arc>", + NOW - DAY, + )) + .expect("record"); + + let echo = store + .find_echo("arc>,", &ctx("new", Some("doc-b"))) + .expect("query") + .expect("echo"); + assert_eq!(echo.matched_anchor, "Arc>"); + } + + #[test] + fn a_two_character_query_matches_through_bigrams() { + let (_dir, mut store) = store(); + store + .record_turn(turn("old", "code", Some("doc-a"), "检索树", NOW - DAY)) + .expect("record"); + + let echo = store + .find_echo("检索", &ctx("new", Some("doc-b"))) + .expect("query") + .expect("a two-character Chinese query must be searchable"); + assert_eq!(echo.matched_anchor, "检索树"); + } +} diff --git a/docs/adr/0012-linux-native-companion-architecture.md b/docs/adr/0012-linux-native-companion-architecture.md index 83e0e52..41ba7b0 100644 --- a/docs/adr/0012-linux-native-companion-architecture.md +++ b/docs/adr/0012-linux-native-companion-architecture.md @@ -60,6 +60,7 @@ X11 会话 → XGrabKey - X11:`x11rb` 读 PRIMARY。 - AT-SPI `TextProxy` 仅作补充;Chromium/终端常不暴露。 - **不合成 Ctrl+C**;确需按键回写时走 RemoteDesktop/libei + `reis`。 +- 当前实现(`src-tauri/src/lib.rs::capture_current_selection`):Wayland 会话先读 PRIMARY,失败才回退到 XWayland 的 Ctrl+C;X11 会话反过来。原因是原生 Wayland 窗口收不到 X 合成按键,此时 Ctrl+C 可能落到上一个仍有 X 焦点的窗口,读到无关文本。X11 回退需要 `xdotool`,缺失时只走 PRIMARY。 - 读取顺序:先捕获选区,再显示/聚焦 HUD。 - 读取超时 150 ms,原始上限 64 KB,再做 3000 字符 Unicode 截断。 - 失败返回明确的 `CaptureError`,不显示上一轮旧文本。 diff --git a/index.html b/index.html index e59365f..1613d84 100644 --- a/index.html +++ b/index.html @@ -57,6 +57,9 @@ + + +
diff --git a/package.json b/package.json index b2789d7..0e0bdab 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "test": "node --experimental-strip-types src/safe-markdown.test.ts && node --experimental-strip-types src/safe-markdown.challenge.test.ts && node --experimental-strip-types src/generative-ui.test.ts && node --experimental-strip-types src/history-store.test.ts", + "test": "node --experimental-strip-types src/safe-markdown.test.ts && node --experimental-strip-types src/safe-markdown.challenge.test.ts && node --experimental-strip-types src/generative-ui.test.ts && node --experimental-strip-types src/history-store.test.ts && node --experimental-strip-types src/recall.test.ts", "test:contract": "node tests/e2e_runner.mjs", "check": "npm run test && npm run test:contract && npm run build", "tauri": "tauri" diff --git a/scripts/check.sh b/scripts/check.sh index 1c9ea97..359fdb6 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -26,6 +26,12 @@ done echo "==> cargo test (core)" cargo test -p glint-core +echo "==> cargo clippy (recall)" +cargo clippy -p glint-recall --all-targets -- -D warnings + +echo "==> cargo test (recall)" +cargo test -p glint-recall + echo "==> cargo clippy (shell)" cargo clippy -p glint --all-targets -- -D warnings diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 04b7d84..581b335 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -16,6 +16,7 @@ tauri-build = { version = "2", features = [] } [dependencies] glint-core = { path = "../crates/glint-core" } +glint-recall = { path = "../crates/glint-recall" } tauri = { version = "2", features = ["tray-icon"] } tauri-plugin-opener = "2" tauri-plugin-single-instance = "2" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 016d560..ea5ca48 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,6 +8,8 @@ pub mod tools; use capture::{SelectionResult, capture_selected_text, get_cursor_pos}; use config::load_config; +use glint_core::{SessionType, detect_session}; +use glint_recall::{Echo, RecallStore, TurnRecord}; use llm::{QueryRequest, StreamChunk, stream_explanation}; use preferences::{ fetch_models, get_app_config, hide_preferences_window, open_preferences_window, @@ -271,6 +273,35 @@ pub fn toggle_window_visibility(app: &AppHandle) { } } +/// Read the current selection. +/// +/// On a Wayland session the synthetic Ctrl+C can only reach XWayland clients. +/// When a native Wayland window is focused, the key goes to whichever X window +/// still holds X focus, which may be an unrelated app. PRIMARY is set by the +/// mouse selection itself, so on Wayland it is read first. On X11 the +/// clipboard round-trip is fresher, so it stays first there. +fn capture_current_selection() -> Option<(String, i32, i32, Option)> { + let from_primary = || { + glint_core::platform_sensor() + .capture() + .ok() + .map(|candidate| { + let (cx, cy) = get_cursor_pos(); + (candidate.text, cx, cy, None) + }) + }; + + let from_clipboard = || { + capture_selected_text().map(|res| (res.text, res.cursor_x, res.cursor_y, res.window_title)) + }; + + if matches!(detect_session(), SessionType::Wayland) { + from_primary().or_else(from_clipboard) + } else { + from_clipboard().or_else(from_primary) + } +} + pub fn trigger_capture_and_show(app: &AppHandle) { eprintln!("[Glint] trigger_capture_and_show"); if let Some(window) = app.get_webview_window("hud") { @@ -279,14 +310,12 @@ pub fn trigger_capture_and_show(app: &AppHandle) { window.is_visible(), window.outer_size() ); - let (text, cursor_x, cursor_y, window_title) = match capture_selected_text() { - Some(res) => (res.text, res.cursor_x, res.cursor_y, res.window_title), - None => { + let (text, cursor_x, cursor_y, window_title) = + capture_current_selection().unwrap_or_else(|| { let (cx, cy) = get_cursor_pos(); let window_title = crate::platform::get_active_window_title_opt(); - ("".to_string(), cx, cy, window_title) - } - }; + (String::new(), cx, cy, window_title) + }); // MUST be read before `show()`: once the HUD takes focus, KWin reports // "Glint" as the foreground app and every selection would look like the @@ -621,9 +650,114 @@ fn submit_snipping_result( } } +// --- Local recall (echo) --------------------------------------------------- +// +// Completed turns are mirrored into a SQLite file next to the app's own data, +// so a later selection can ask "have I asked about this before?" without a +// network round-trip. The store opens lazily: no file exists until the first +// turn is recorded, and `recall_wipe` removes it with the rest of the history. + +/// How old a turn about the *same* document must be before it can echo. Asking +/// the same question twice in one sitting is not a memory worth surfacing. +const SAME_DOCUMENT_ECHO_MIN_AGE_MS: i64 = 24 * 60 * 60 * 1000; + +#[derive(Default)] +struct RecallState { + store: std::sync::Mutex>, +} + +impl RecallState { + fn with_store( + &self, + app: &AppHandle, + run: impl FnOnce(&mut RecallStore) -> Result, + ) -> Result { + let mut guard = self + .store + .lock() + .map_err(|_| "recall state poisoned".to_string())?; + + if guard.is_none() { + let store = RecallStore::open(&recall_db_path(app)?).map_err(|err| err.to_string())?; + *guard = Some(store); + } + + let store = guard.as_mut().expect("store initialized above"); + run(store).map_err(|err| err.to_string()) + } +} + +fn recall_db_path(app: &AppHandle) -> Result { + let dir = app.path().app_data_dir().map_err(|err| err.to_string())?; + Ok(dir.join("recall.sqlite3")) +} + +fn unix_now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis() as i64) + .unwrap_or(0) +} + +#[tauri::command] +fn recall_find_echo( + app: AppHandle, + state: tauri::State<'_, RecallState>, + query: String, + thread_id: String, + doc_key: Option, +) -> Result, String> { + // Never create the store just because the user selected something: an + // empty database is still a file on disk. + if !recall_db_path(&app)?.exists() { + return Ok(None); + } + + let ctx = glint_recall::EchoContext { + current_thread_id: thread_id, + current_doc_key: doc_key, + now_ms: unix_now_ms(), + min_age_ms: SAME_DOCUMENT_ECHO_MIN_AGE_MS, + }; + + state.with_store(&app, |store| store.find_echo(&query, &ctx)) +} + +#[tauri::command] +fn recall_record_turn( + app: AppHandle, + state: tauri::State<'_, RecallState>, + turn: TurnRecord, +) -> Result<(), String> { + state.with_store(&app, |store| store.record_turn(turn)) +} + +#[tauri::command] +fn recall_forget_thread( + app: AppHandle, + state: tauri::State<'_, RecallState>, + thread_id: String, +) -> Result<(), String> { + state.with_store(&app, |store| store.forget_thread(&thread_id).map(|_| ())) +} + +#[tauri::command] +fn recall_wipe(app: AppHandle, state: tauri::State<'_, RecallState>) -> Result<(), String> { + { + let mut guard = state + .store + .lock() + .map_err(|_| "recall state poisoned".to_string())?; + *guard = None; + } + + RecallStore::wipe(&recall_db_path(&app)?).map_err(|err| err.to_string()) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() + .manage(RecallState::default()) // 关键防御:单实例守卫 (Single Instance Guard)。 // 杜绝重复打开进程导致任务栏托盘出现多个冗余图标,重复启动时直接激活并聚焦已有视窗。 .plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| { @@ -764,7 +898,11 @@ pub fn run() { test_llm_connection, open_snipping_tool, close_snipping_tool, - submit_snipping_result + submit_snipping_result, + recall_find_echo, + recall_record_turn, + recall_forget_thread, + recall_wipe ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/main.ts b/src/main.ts index bc9d171..73b0efb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,6 +11,13 @@ import { type ToolCallPayload, } from "./history-store.ts"; import { StreamingRenderPipeline } from "./streaming-pipeline.ts"; +import { + conclusionFrom, + echoLineText, + sanitizeEchoText, + withPriorAnswer, + type RecallEcho, +} from "./recall.ts"; export type GlintTheme = "dark" | "aurora" | "amethyst" | "epaper"; @@ -68,6 +75,7 @@ const btnClearHistory = document.getElementById("btn-clear-history") as HTMLButt const hostBadge = document.getElementById("host-badge") as HTMLDivElement | null; const hostTitle = document.getElementById("host-title") as HTMLSpanElement | null; +const echoLine = document.getElementById("echo-line") as HTMLDivElement | null; const cardHandle = document.querySelector(".card-anchor-handle") as HTMLElement; const resizeGrip = document.getElementById("resize-grip") as HTMLElement; const truncationCapsule = document.getElementById("truncation-capsule") as HTMLDivElement | null; @@ -185,6 +193,7 @@ function rememberThread( function resetThread() { currentThread = null; + renderEchoLine(null); } /** 线索分隔条:让「续接」这件事始终可见,绝不静默。 */ @@ -235,12 +244,73 @@ function getMessageDisplaySummary(content: MessageContent): string { return textPart ? textPart.text : "【屏幕截图与标注】"; } +// ── 回声 (echo) ────────────────────────────────────────────────────── +// 同一概念在别的线索里问过时,在宿主徽标下多一行来源。一次只给一条; +// 未命中时整行不渲染,也没有「暂无历史」这种空状态。判定与清洗规则在 +// `recall.ts`,这里只负责 IPC 与 DOM。 + +async function fetchEcho( + text: string, + threadId: string, + docKey: string | null +): Promise { + if (!isTauri()) return null; + try { + return await invoke("recall_find_echo", { + query: text, + threadId, + docKey, + }); + } catch { + // 回声只是增强;它失败时这一轮必须照常回答。 + return null; + } +} + +function renderEchoLine(echo: RecallEcho | null) { + if (!echoLine) return; + if (!echo) { + echoLine.style.display = "none"; + echoLine.textContent = ""; + return; + } + + echoLine.textContent = echoLineText(echo); + echoLine.style.display = "block"; +} + +function recordRecallTurn(anchor: string, answer: string) { + if (!isTauri()) return; + + const cleanAnchor = sanitizeEchoText(anchor).slice(0, 200); + const conclusion = conclusionFrom(answer); + if (!cleanAnchor || !conclusion) return; + + void invoke("recall_record_turn", { + turn: { + thread_id: currentSessionId || "", + app_class: (currentThread?.appClass || "").trim().toLowerCase(), + doc_key: currentThread?.docKey ?? null, + anchor: cleanAnchor, + conclusion, + follow_up: null, + completed_at_ms: Date.now(), + }, + }).catch(() => { + // 记不上不影响这一轮:答案已经在屏幕上了。 + }); +} + // 执行流式解释查询并绑定当前会话 async function executeExplain( messages: ChatMessage[], sessionId: number, targetElementOverride?: HTMLElement, - options?: { label?: string } + options?: { + label?: string; + echoQuery?: { text: string; threadId: string; docKey: string | null }; + recordAnchor?: string; + } ) { if (sessionId !== activeSessionId) return; @@ -274,6 +344,22 @@ async function executeExplain( activePipeline = pipeline; pipeline.beginTurn({ targetOverride: targetOutputEl }); + // 回声查询必须在请求发出前完成:命中就多一行来源,并把旧结论作为数据块 + // 附在本轮消息上。未命中时整行不渲染,也不影响这一轮。 + let echo: RecallEcho | null = null; + if (options?.echoQuery) { + echo = await fetchEcho( + options.echoQuery.text, + options.echoQuery.threadId, + options.echoQuery.docKey + ); + if (sessionId !== activeSessionId) { + finishTurn(); + return; + } + renderEchoLine(echo); + } + onEvent.onmessage = (chunk: StreamChunk) => { // 丢弃已经过期会话的消息 if (sessionId !== activeSessionId) return; @@ -294,6 +380,13 @@ async function executeExplain( // 持久化保存至本地历史存储 saveCurrentSessionToHistory(fullTurnText); + + // 只记成功的、有选区的轮次。追问与截图不写入回声:它们的锚点不是 + // 用户真正划中的概念。 + if (options?.recordAnchor) { + recordRecallTurn(options.recordAnchor, fullTurnText); + } + finishTurn(); } else if (chunk.type === "Error") { statusIndicator.style.display = "none"; @@ -304,7 +397,9 @@ async function executeExplain( }; const req: QueryRequest = { - messages, + // 回声只进入这一次请求。conversationMessages 本身保持干净,否则它会 + // 出现在卡片里,也会被写进历史存储。 + messages: echo ? withPriorAnswer(messages, echo) : messages, api_key: getStoredSetting("glint_api_key", ""), base_url: getStoredSetting("glint_base_url", "https://api.deepseek.com/v1"), model: getStoredSetting("glint_model", "deepseek-chat"), @@ -505,11 +600,24 @@ function startNewQuery( resetThread(); frozenHostLabel = hostLabelFor(windowTitle ?? null, identity.appClass); conversationMessages = [{ role: "user", content: truncatedResult.text }]; - executeExplain(conversationMessages, activeSessionId); + executeExplain(conversationMessages, activeSessionId, undefined, { + echoQuery: { + text: truncatedResult.text, + threadId: currentSessionId, + docKey: identity.docKey, + }, + recordAnchor: truncatedResult.text, + }); } else { conversationMessages.push({ role: "user", content: truncatedResult.text }); executeExplain(conversationMessages, activeSessionId, undefined, { label: `再次划词 · 第 ${(currentThread?.turnCount ?? 0) + 1} 轮`, + echoQuery: { + text: truncatedResult.text, + threadId: currentSessionId, + docKey: identity.docKey, + }, + recordAnchor: truncatedResult.text, }); } @@ -792,6 +900,11 @@ function renderHistoryList(filterKeyword?: string) { delBtn.addEventListener("click", (e) => { e.stopPropagation(); HistoryStore.delete(sess.id); + // 删除会话时同步清掉它留下的回声,否则下次划到同一概念,这条 + // 已经删掉的结论还会冒出来。 + if (isTauri()) { + void invoke("recall_forget_thread", { threadId: sess.id }).catch(() => {}); + } renderHistoryList(historySearchInput?.value); }); @@ -916,6 +1029,9 @@ if (btnClearHistory) { btnClearHistory.addEventListener("click", () => { if (confirm("确定清空全部历史会话记录吗?")) { HistoryStore.clear(); + if (isTauri()) { + void invoke("recall_wipe").catch(() => {}); + } renderHistoryList(); } }); diff --git a/src/recall.test.ts b/src/recall.test.ts new file mode 100644 index 0000000..a43fa6b --- /dev/null +++ b/src/recall.test.ts @@ -0,0 +1,99 @@ +import { + conclusionFrom, + echoAppLabel, + echoLineText, + formatEchoAge, + sanitizeEchoText, + withPriorAnswer, + type RecallEcho, +} from "./recall.ts"; +import type { ChatMessage } from "./history-store.ts"; + +const assert = { + strictEqual(actual: T, expected: T, msg?: string) { + if (actual !== expected) { + throw new Error( + `${msg || "assertion failed"}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}` + ); + } + }, + ok(value: unknown, msg?: string) { + if (!value) throw new Error(msg || "expected a truthy value"); + }, +}; + +const echo: RecallEcho = { + turn_id: 1, + conclusion: "TLB 命中可以省掉一次页表遍历。", + app_class: "code", + age_ms: 3 * 24 * 60 * 60 * 1000, + thread_id: "old-thread", + matched_anchor: "TLB", +}; + +// Age reads as words, never as a timestamp. +assert.strictEqual(formatEchoAge(30_000), "刚才"); +assert.strictEqual(formatEchoAge(3 * 3_600_000), "3 小时前"); +assert.strictEqual(formatEchoAge(3 * 86_400_000), "3 天前"); +assert.strictEqual(formatEchoAge(70 * 86_400_000), "2 个月前"); + +// App classes become names people recognise. +assert.strictEqual(echoAppLabel("code"), "VS Code"); +assert.strictEqual(echoAppLabel("vscode"), "VS Code"); +assert.strictEqual(echoAppLabel("firefox"), "Firefox"); +assert.strictEqual(echoAppLabel("com.anthropic.claude"), "Claude"); +assert.strictEqual(echoAppLabel("org.kde.konsole"), "终端"); +assert.strictEqual(echoAppLabel(""), "", "an unknown host is omitted, not named 'here'"); + +// The HUD line reads well with and without a host label. +assert.strictEqual( + echoLineText(echo), + "↺ 3 天前在 VS Code 问过 · TLB 命中可以省掉一次页表遍历。" +); +assert.strictEqual( + echoLineText({ ...echo, app_class: "" }), + "↺ 3 天前问过 · TLB 命中可以省掉一次页表遍历。" +); + +// A stored answer cannot close the data block early. +assert.strictEqual(sanitizeEchoText("结论被截断"), "结论被截断"); +assert.strictEqual(sanitizeEchoText("结论"), "结论"); +assert.strictEqual(sanitizeEchoText("a\u202Eb\u2066c"), "abc", "bidi controls must go"); +assert.strictEqual(sanitizeEchoText(" a \n b "), "a b"); + +// The request copy carries the block; the transcript itself stays clean. +const messages: ChatMessage[] = [{ role: "user", content: "TLB 是什么" }]; +const withEcho = withPriorAnswer(messages, echo); +assert.strictEqual(messages[0].content, "TLB 是什么", "the original must not change"); + +const content = withEcho[0].content as string; +assert.ok(content.startsWith("\n"), "the block opens the turn"); +assert.ok(content.endsWith("TLB 是什么"), "the selection stays last"); +assert.ok(content.includes("3 天前在 VS Code 问过"), "the source is named"); +assert.ok(content.includes("以当前证据为准"), "the conflict rule is present"); + +// Multimodal content keeps its parts and gains the block on the text part. +const multimodal: ChatMessage[] = [ + { + role: "user", + content: [ + { type: "text", text: "看图" }, + { type: "image_url", image_url: { url: "data:image/png;base64,AA" } }, + ], + }, +]; +const shifted = withPriorAnswer(multimodal, echo); +const parts = shifted[0].content; +assert.ok(Array.isArray(parts), "multimodal content stays an array"); +const typed = parts as Array<{ type: string; text?: string }>; +assert.strictEqual(typed.length, 2); +assert.ok((typed[0].text || "").startsWith(""), "the text part carries the block"); +assert.strictEqual(typed[1].type, "image_url", "the image part is untouched"); + +// The conclusion is the first sentence, not the first 120 characters. +assert.strictEqual(conclusionFrom("第一句。第二句展开。"), "第一句。"); +assert.strictEqual(conclusionFrom("```code``` 实际结论。"), "实际结论。"); +assert.strictEqual(conclusionFrom("Version 1.2 works. Next sentence."), "Version 1.2 works."); +assert.strictEqual(conclusionFrom(""), ""); + +console.log("ALL RECALL TESTS PASSED!"); diff --git a/src/recall.ts b/src/recall.ts new file mode 100644 index 0000000..fef00bf --- /dev/null +++ b/src/recall.ts @@ -0,0 +1,127 @@ +/** + * Echo helpers: the pure half of Glint's local recall. + * + * Nothing here touches Tauri, the DOM, or storage. `main.ts` owns the I/O, so + * the rules that decide *what the user sees and what the model is told* can be + * tested in a plain Node process. + */ + +import type { ChatMessage } from "./history-store.ts"; + +/** A prior answer about the same concept, from `glint-recall`. */ +export interface RecallEcho { + turn_id: number; + conclusion: string; + app_class: string; + age_ms: number; + thread_id: string; + matched_anchor: string; +} + +export function formatEchoAge(ageMs: number): string { + const minutes = Math.floor(ageMs / 60_000); + if (minutes < 60) return "刚才"; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours} 小时前`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days} 天前`; + const months = Math.floor(days / 30); + return months < 12 ? `${months} 个月前` : `${Math.floor(months / 12)} 年前`; +} + +export function echoAppLabel(appClass: string): string { + const key = appClass.trim().toLowerCase(); + if (!key) return ""; + if (key.includes("code") || key.includes("vscode")) return "VS Code"; + if (key.includes("claude")) return "Claude"; + if (key.includes("firefox")) return "Firefox"; + if (key.includes("chrome") || key.includes("chromium")) return "Chrome"; + if (key.includes("edge")) return "Edge"; + if (key.includes("konsole") || key.includes("terminal")) return "终端"; + if (key.includes("okular")) return "Okular"; + + // Linux app classes are often reverse-DNS (`com.anthropic.claude`). The last + // segment is the part a person recognises. + const last = key.includes(".") ? (key.split(".").pop() ?? "") : key; + return last ? last.charAt(0).toUpperCase() + last.slice(1) : ""; +} + +/** `在 VS Code ` when there is a source, nothing when there is not. */ +function echoWhere(echo: RecallEcho): string { + const app = echoAppLabel(echo.app_class); + return app ? `在 ${app} ` : ""; +} + +/** + * A stored conclusion is model output derived from untrusted documents. Strip + * the data-block delimiters and invisible controls before it goes back into a + * prompt, otherwise a prior answer can close `` early. + */ +export function sanitizeEchoText(text: string): string { + return text + .replace(/<\/?prior_answer>/gi, "") + .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u202A-\u202E\u2066-\u2069]/g, "") + .replace(/\s+/g, " ") + .trim(); +} + +/** The single HUD line. A miss never calls this: there is no empty state. */ +export function echoLineText(echo: RecallEcho): string { + const conclusion = sanitizeEchoText(echo.conclusion); + return `↺ ${formatEchoAge(echo.age_ms)}${echoWhere(echo)}问过 · ${conclusion}`; +} + +/** + * Attach the prior answer to the current user turn for this request only. + * The conversation array itself stays clean: the block must not appear in the + * card or in the persisted history. + */ +export function withPriorAnswer(messages: ChatMessage[], echo: RecallEcho): ChatMessage[] { + if (messages.length === 0) return messages; + + const conclusion = sanitizeEchoText(echo.conclusion); + if (!conclusion) return messages; + + const block = + `\n` + + `这是该用户 ${formatEchoAge(echo.age_ms)}${echoWhere(echo)}问过的同一概念,当时的结论:${conclusion}\n` + + `这是你此前的回答,可能不完整或有误;与当前证据冲突时以当前证据为准,并直接说出差异。与当前问题无关时不要提及。\n` + + `\n\n`; + + const copy = messages.slice(); + const last = copy[copy.length - 1]; + + if (typeof last.content === "string") { + copy[copy.length - 1] = { role: last.role, content: block + last.content }; + } else { + const parts = last.content.slice(); + const firstText = parts.findIndex((part) => part.type === "text"); + if (firstText >= 0) { + const part = parts[firstText] as { type: "text"; text: string }; + parts[firstText] = { type: "text", text: block + part.text }; + } else { + parts.unshift({ type: "text", text: block }); + } + copy[copy.length - 1] = { role: last.role, content: parts }; + } + + return copy; +} + +/** The first sentence is the conclusion; the rest stays in the old card. */ +export function conclusionFrom(answer: string): string { + const clean = answer + .replace(/```[\s\S]*?```/g, " ") + .replace(/\s+/g, " ") + .trim(); + if (!clean) return ""; + + // Chinese sentence marks need no trailing space. + const cjk = clean.match(/^(.{1,120}?[。!?])/); + if (cjk) return cjk[1].trim(); + + // A Latin period ends a sentence only when whitespace or the end follows; + // otherwise it is an abbreviation or a version number. + const latin = clean.match(/^(.{1,120}?[.!?])(?:\s|$)/); + return (latin ? latin[1] : clean.slice(0, 120)).trim(); +} diff --git a/src/styles.css b/src/styles.css index 3a067c4..5f6b723 100644 --- a/src/styles.css +++ b/src/styles.css @@ -248,6 +248,22 @@ html, body { background-color: var(--accent-cyan); } +/* 回声行:一次命中只占一行;未命中时整行不出现,也没有空状态。 */ +.echo-line { + margin: 0 12px 6px 12px; + padding: 4px 9px; + border-left: 2px solid rgba(129, 140, 248, 0.5); + background: rgba(129, 140, 248, 0.07); + border-radius: 0 6px 6px 0; + color: var(--text-muted); + font-size: 11px; + line-height: 1.45; + letter-spacing: 0.1px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + /* 玻璃图标按钮 */ .glass-icon-btn { width: 24px; diff --git a/tests/tier1_features.test.mjs b/tests/tier1_features.test.mjs index d8713f7..a32c8cd 100644 --- a/tests/tier1_features.test.mjs +++ b/tests/tier1_features.test.mjs @@ -23,13 +23,14 @@ import { SRC_DIR, SRC_TAURI_DIR, GLINT_CORE_DIR, + CRATES_DIR, README_PATH, GLINT_HUD_DIR, CONSTANTS, SKIP_SHELL, } from "./test_config.mjs"; -export const tier1Suite = new TestRegistry("Tier 1: Feature Coverage (F1 - F18)"); +export const tier1Suite = new TestRegistry("Tier 1: Feature Coverage (F1 - F19)"); /// Read a module from the headless core, where the engine now lives. function readCoreSource(fileName) { @@ -1174,3 +1175,49 @@ tier1Suite.test( }, { milestone: "M4", feature: "F18" } ); + +// ========================================== +// Feature 19: Local recall echo +// ========================================== +tier1Suite.test( + "F19-TC1: glint-recall is a workspace member and indexes turns with FTS5", + async () => { + assert.ok( + fs.existsSync(path.join(CRATES_DIR, "glint-recall", "Cargo.toml")), + "crates/glint-recall/Cargo.toml must exist" + ); + const root = fs.readFileSync(path.join(PROJECT_ROOT, "Cargo.toml"), "utf-8"); + assert.match(root, /crates\/glint-recall/); + const lib = fs.readFileSync(path.join(CRATES_DIR, "glint-recall", "src", "lib.rs"), "utf-8"); + assert.match(lib, /CREATE VIRTUAL TABLE IF NOT EXISTS turns_fts USING fts5/); + assert.match(lib, /content\s*=\s*'turns'/); + }, + { milestone: "M5", feature: "F19" } +); + +tier1Suite.test( + "F19-TC2: Tauri exposes the recall commands", + async () => { + const lib = fs.readFileSync(path.join(SRC_TAURI_DIR, "src", "lib.rs"), "utf-8"); + for (const command of [ + "recall_find_echo", + "recall_record_turn", + "recall_forget_thread", + "recall_wipe", + ]) { + assert.ok(lib.includes(command), `src-tauri must register ${command}`); + } + }, + { milestone: "M5", feature: "F19" } +); + +tier1Suite.test( + "F19-TC3: The HUD has one echo line and no empty state", + async () => { + const html = fs.readFileSync(path.join(PROJECT_ROOT, "index.html"), "utf-8"); + assert.match(html, /id="echo-line"/); + const main = fs.readFileSync(path.join(SRC_DIR, "main.ts"), "utf-8"); + assert.match(main, /echoQuery/); + }, + { milestone: "M5", feature: "F19" } +);