Merge p6/embed: local embeddings + document shelf (P6 step 2)

This commit is contained in:
daniel wang 2026-11-09 20:23:38 +08:00
commit 9d14a369e2
8 changed files with 658 additions and 2 deletions

View File

@ -802,7 +802,7 @@ pub const QUICK_TROUBLE_MAX: usize = 2;
fn only_looks(name: &str) -> bool { fn only_looks(name: &str) -> bool {
matches!( matches!(
name, name,
"computer_use_screenshot" | "computer_use_zoom" | "computer_use_show" | "computer_use_check_permissions" | "browser_read" | "browser_screenshot" | "browser_elements" | "browser_find" | "browser_table" | "browser_tabs" | "browser_wait" | "web_fetch" | "read_file" | "list_dir" | "search" | "read_terminal" | "list_computers" | "list_bots" | "list_routines" | "read_page" | "use_skill" | "react" | "show_card" "computer_use_screenshot" | "computer_use_zoom" | "computer_use_show" | "computer_use_check_permissions" | "browser_read" | "browser_screenshot" | "browser_elements" | "browser_find" | "browser_table" | "browser_tabs" | "browser_wait" | "web_fetch" | "read_file" | "list_dir" | "search" | "read_terminal" | "list_computers" | "list_bots" | "list_routines" | "read_page" | "use_skill" | "react" | "show_card" | "search_documents"
) )
} }

View File

@ -98,6 +98,25 @@ enum SettingsTab {
Remote, Remote,
} }
/// The folders the document indexer reads, for the background thread (mirrors
/// state.doc_folders; empty = this machine's Documents).
static DOC_FOLDERS: std::sync::RwLock<Vec<String>> = std::sync::RwLock::new(Vec::new());
pub fn doc_folders() -> Vec<String> {
let v = DOC_FOLDERS.read().unwrap().clone();
if !v.is_empty() {
return v;
}
match std::env::var("HOME") {
Ok(h) => vec![format!("{h}/Documents")],
Err(_) => vec![],
}
}
pub fn set_doc_folders(v: Vec<String>) {
*DOC_FOLDERS.write().unwrap() = v;
}
pub struct App { pub struct App {
state: State, state: State,
pal: Palette, pal: Palette,
@ -120,6 +139,8 @@ pub struct App {
delivered: Vec<(u64, String)>, delivered: Vec<(u64, String)>,
/// Save at the next frame (a typed message just entered a chat), not up to 2 s later. /// Save at the next frame (a typed message just entered a chat), not up to 2 s later.
save_now: bool, save_now: bool,
/// 設定 › 一般 › 文件索引: the path being typed for a new folder.
doc_new: String,
/// Turns that AnyBot stopping cut off, found at start (see resume_interrupted). /// Turns that AnyBot stopping cut off, found at start (see resume_interrupted).
interrupted: Vec<crate::oplog::Interrupted>, interrupted: Vec<crate::oplog::Interrupted>,
last_tick: Instant, last_tick: Instant,
@ -228,6 +249,13 @@ impl App {
// ANYBOT_SHOT_REAL=1: a screenshot of the real data (and connections), not the demo. // ANYBOT_SHOT_REAL=1: a screenshot of the real data (and connections), not the demo.
let demo = screenshot.is_some() && std::env::var("ANYBOT_SHOT_REAL").is_err(); let demo = screenshot.is_some() && std::env::var("ANYBOT_SHOT_REAL").is_err();
let mut state = if demo { State::demo() } else { State::load() }; let mut state = if demo { State::demo() } else { State::load() };
// 語意搜尋的地基 (P6): vectors come from the person's own Ollama; folders and vectors
// fill themselves in the background. No Ollama: words only, everything still works.
if !demo {
crate::embed::configure(&crate::embed::ollama_base(&state.provider(crate::providers::ProviderKind::Ollama).base_url), &state.embed_model);
set_doc_folders(state.doc_folders.clone());
crate::embed::start();
}
// After a crash: the work log puts the chats right (what was running, what was typed), // After a crash: the work log puts the chats right (what was running, what was typed),
// the chats are saved, then the log closes the cut-off turns. Not for a screenshot run of // the chats are saved, then the log closes the cut-off turns. Not for a screenshot run of
// the real data: that is a second copy next to the one that owns the log. // the real data: that is a second copy next to the one that owns the log.
@ -258,6 +286,7 @@ impl App {
pending_turns: vec![], pending_turns: vec![],
delivered: vec![], delivered: vec![],
save_now: false, save_now: false,
doc_new: String::new(),
interrupted, interrupted,
last_tick: Instant::now(), last_tick: Instant::now(),
show_routines: None, show_routines: None,
@ -3214,6 +3243,52 @@ impl App {
self.settings_tab = SettingsTab::Remote; self.settings_tab = SettingsTab::Remote;
} }
}); });
section(ui, "記憶與文件", p);
let emb = crate::embed::model();
let on = !emb.is_empty();
let desc = if on {
format!("開著:Bot 用你自己的 Ollama({emb})讀懂「那篇講語法的筆記」這種話,檔案和記憶都算")
} else {
"關著:搜尋照舊用關鍵字,一切正常。裝個 Ollama 的 embedding 模型(如 bge-m3)就可以開".to_string()
};
setting_row(ui, p, "語意搜尋", &desc, |ui| {
if ui.add(secondary_button(if on { "關閉" } else { "開啟" })).clicked() {
self.state.embed_model = if on { "off".to_string() } else { String::new() };
crate::embed::configure(&crate::embed::ollama_base(&self.state.provider(crate::providers::ProviderKind::Ollama).base_url), &self.state.embed_model);
self.dirty = true;
}
});
let shown = doc_folders().join(" · ");
setting_row(ui, p, "文件索引", &format!("Bot 在這些資料夾的文字檔(md/txt/csv/程式碼)裡找答案,全程只在本機:{shown}"), |_ui| {});
ui.horizontal(|ui| {
for f in self.state.doc_folders.clone() {
if ui.add(secondary_button(&format!("移除 {f}"))).clicked() {
self.state.doc_folders.retain(|x| x != &f);
set_doc_folders(self.state.doc_folders.clone());
self.dirty = true;
}
}
});
ui.horizontal(|ui| {
ui.add_sized([280.0, 24.0], egui::TextEdit::singleline(&mut self.doc_new).hint_text("資料夾路徑,例如 ~/筆記"));
if ui.add(secondary_button("新增資料夾")).clicked() {
let mut f = self.doc_new.trim().to_string();
if f.starts_with("~/") {
if let Ok(h) = std::env::var("HOME") {
f = format!("{h}/{}", &f[2..]);
}
}
if f.is_empty() {
} else if std::path::Path::new(&f).is_dir() && !self.state.doc_folders.contains(&f) {
self.state.doc_folders.push(f);
set_doc_folders(self.state.doc_folders.clone());
self.dirty = true;
self.doc_new.clear();
} else {
ui.label(RichText::new("這個資料夾不存在").size(FONT_SM).color(p.text2));
}
}
});
section(ui, "外觀", p); section(ui, "外觀", p);
setting_row(ui, p, "主題", "為 AnyBot 選擇淺色或深色外觀", |ui| { setting_row(ui, p, "主題", "為 AnyBot 選擇淺色或深色外觀", |ui| {
for (th, n) in [(Theme::System, "跟隨系統"), (Theme::Dark, "深色"), (Theme::Light, "淺色")] { for (th, n) in [(Theme::System, "跟隨系統"), (Theme::Dark, "深色"), (Theme::Light, "淺色")] {
@ -4280,6 +4355,7 @@ fn system_prompt(b: &Bot, st: &State, computers: &[tools::Target], default_compu
s.push_str(&format!("\n\n你也在這些訊息管道回覆別人:{}。以「[訊息管道]」開頭的訊息來自那裡,你這一回合最後的回答會自動傳回給對方,所以要簡潔、直接回答對方,不要用表格,也不要提到 AnyBot 的內部操作。對方不是使用者本人:不要透露使用者的私人資料,要動到使用者的電腦或帳號前先確認。", chans.join("、"))); s.push_str(&format!("\n\n你也在這些訊息管道回覆別人:{}。以「[訊息管道]」開頭的訊息來自那裡,你這一回合最後的回答會自動傳回給對方,所以要簡潔、直接回答對方,不要用表格,也不要提到 AnyBot 的內部操作。對方不是使用者本人:不要透露使用者的私人資料,要動到使用者的電腦或帳號前先確認。", chans.join("、")));
} }
s.push_str(&crate::memory::prompt_section(memories, &st.skills)); s.push_str(&crate::memory::prompt_section(memories, &st.skills));
s.push_str("使用者問「自己的檔案」(那份報表、寫過的筆記、存過的檔案在哪):先用 search_documents,它會用意思加關鍵字搜對方索引過的資料夾,不記得確切用詞也找得到。");
let mcp_notes = crate::mcp::instructions(&st.mcp_servers); let mcp_notes = crate::mcp::instructions(&st.mcp_servers);
let mcp_tools = crate::mcp::status(); let mcp_tools = crate::mcp::status();
if !mcp_tools.is_empty() { if !mcp_tools.is_empty() {

147
src/docindex.rs Normal file
View File

@ -0,0 +1,147 @@
//! 文件索引 (P6, step 2): the person's own folders, searchable by meaning.
//!
//! 「我上個月那份報表」 is a question about files, not about chats: the answer sits in
//! ~/Documents. One background sweep (embed.rs's thread) walks the folders the person
//! picked, keeps small text chunks of the text-shaped files in SQLite (FTS + vectors),
//! and refreshes them by modification time — a Khoj for the folder, nothing leaves the
//! machine, and the model reaches it with one tool: search_documents.
use std::path::Path;
/// Text-shaped files worth reading; images, pdfs and binaries are left for other tools.
pub const EXTS: [&str; 10] = ["md", "txt", "csv", "log", "rs", "py", "js", "ts", "html", "json"];
/// Chunks answer better than whole files: about this long, overlapping so a sentence
/// cut in half is whole in one of them.
const CHUNK: usize = 800;
const OVERLAP: usize = 120;
const MAX_FILE_BYTES: u64 = 2_000_000;
/// One sweep: (re-)index what changed, drop what's gone.
pub fn sweep(folders: &[String]) {
if folders.is_empty() {
return;
}
let mut seen: Vec<(String, i64)> = vec![];
for f in folders {
walk(Path::new(f), 0, &mut seen);
}
for (path, _) in crate::store::doc_mtimes() {
if !seen.iter().any(|(p, _)| p == &path) {
crate::store::doc_remove(&path);
}
}
}
fn walk(dir: &Path, depth: usize, seen: &mut Vec<(String, i64)>) {
if depth > 5 {
return;
}
let Ok(rd) = std::fs::read_dir(dir) else { return };
for e in rd.flatten() {
let name = e.file_name().to_string_lossy().to_string();
if name.starts_with('.') || name == "node_modules" || name == "target" {
continue;
}
let p = e.path();
if p.is_dir() {
walk(&p, depth + 1, seen);
continue;
}
let ext = p.extension().map(|x| x.to_string_lossy().to_lowercase()).unwrap_or_default();
if !EXTS.contains(&ext.as_str()) {
continue;
}
let Ok(md) = p.metadata() else { continue };
if md.len() < 32 || md.len() > MAX_FILE_BYTES {
continue;
}
let mtime = md.modified().map(|t| t.duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0)).unwrap_or(0);
seen.push((p.to_string_lossy().to_string(), mtime));
if crate::store::doc_mtime_is(&p.to_string_lossy(), mtime) {
continue; // unchanged since the last sweep
}
let Ok(bytes) = std::fs::read(&p) else { continue };
let text = String::from_utf8_lossy(&bytes).to_string();
let chunks = chunk(&text);
if !chunks.is_empty() {
crate::store::doc_replace(&p.to_string_lossy(), mtime, &chunks);
}
}
}
/// Paragraph-first chunks of ~CHUNK characters, overlapping.
pub fn chunk(text: &str) -> Vec<String> {
let mut out: Vec<String> = vec![];
let mut cur = String::new();
for para in text.split("\n\n") {
let p = para.trim();
if p.is_empty() {
continue;
}
if cur.chars().count() + p.chars().count() > CHUNK && !cur.is_empty() {
let tail: String = cur.chars().skip(cur.chars().count().saturating_sub(OVERLAP)).collect();
out.push(std::mem::take(&mut cur));
cur = tail;
}
if p.chars().count() > CHUNK {
// A wall of text with no breaks: slide a window over it.
let ch: Vec<char> = p.chars().collect();
let mut i = 0;
while i < ch.len() {
let end = (i + CHUNK).min(ch.len());
out.push(ch[i..end].iter().collect());
if end == ch.len() {
break;
}
i = end - OVERLAP;
}
continue;
}
if !cur.is_empty() {
cur.push('\n');
}
cur.push_str(p);
}
if !cur.trim().is_empty() && out.last().map(|l| l.trim() != cur.trim()).unwrap_or(true) {
out.push(cur);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn words_become_answerable_chunks() {
let text = "第一段:预算 42 萬。\n\n第二段:排程在週五。\n\n".repeat(200);
let chunks = chunk(&text);
assert!(chunks.len() >= 4, "a long file is many chunks");
assert!(chunks.iter().all(|c| c.chars().count() <= CHUNK + 40));
// A wall of text with no paragraphs is windowed, not dropped.
let wall = "字".repeat(2000);
let c = chunk(&wall);
assert!(c.len() >= 3 && c.iter().map(|x| x.chars().count()).sum::<usize>() >= 2000);
}
#[test]
fn the_folder_is_watched_and_answered() {
// A real folder, a real sweep, a real question — in the test's own temp dir.
let dir = std::env::temp_dir().join(format!("anybot-docs-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("子資料夾")).unwrap();
std::fs::write(dir.join("報表.md"), "三月營收報表\n\n營收 42 萬,比上月成長。").unwrap();
std::fs::write(dir.join("子資料夾/筆記.txt"), "語法筆記:主詞動詞要一致。").unwrap();
std::fs::write(dir.join("圖片.png"), b"not text").unwrap();
let folders = vec![dir.to_string_lossy().to_string()];
sweep(&folders);
let hits = crate::store::docs_search("營收 42 萬", 5);
assert!(hits.iter().any(|(p, t)| p.ends_with("報表.md") && t.contains("42 萬")), "{hits:?}");
assert!(crate::store::docs_search("主詞動詞", 5).iter().any(|(p, _)| p.ends_with("筆記.txt")));
// The sweep knows every file it saw; delete one and it leaves the index.
std::fs::remove_dir_all(dir.join("子資料夾")).unwrap();
sweep(&folders);
assert!(!crate::store::docs_search("主詞動詞", 5).iter().any(|(p, _)| p.ends_with("筆記.txt")), "gone files leave");
let _ = std::fs::remove_dir_all(&dir);
}
}

173
src/embed.rs Normal file
View File

@ -0,0 +1,173 @@
//! 語意搜尋的地基 (P6, step 2): embeddings from the person's own Ollama.
//!
//! Memories and indexed documents carry a vector beside their words, so 「那篇講語法的筆記」
//! finds a file whose text never says 筆記. Local first: Ollama's embedding endpoint, no key,
//! nothing leaves the machine. No Ollama (or no embedding model in it) is a normal state:
//! every search just answers with words (FTS + LIKE), a little dumber, fully working.
//!
//! One background thread does the slow work — finding the model once, embedding new
//! memories and document chunks in small batches. The turn never waits on it.
use std::sync::Mutex;
use std::time::{Duration, Instant};
struct Cfg {
/// Ollama's root, e.g. http://localhost:11434 (chat's /v1 is stripped).
base: String,
model: String,
}
static CFG: Mutex<Option<Option<Cfg>>> = Mutex::new(None);
/// Last probe/embed failure: retry only after a while, the turn must not pay for it twice.
static DOWN: Mutex<Option<Instant>> = Mutex::new(None);
static STARTED: std::sync::Once = std::sync::Once::new();
/// From the app's settings: Ollama's address (the chat provider's, /v1 stripped) and the
/// model picked ("" = find one installed; "off" = semantic search off).
pub fn configure(base: &str, model: &str) {
let root = base.trim_end_matches("/v1").trim_end_matches('/').to_string();
let mut cfg = CFG.lock().unwrap();
*cfg = Some(if model == "off" {
None
} else if !model.is_empty() {
Some(Cfg { base: root, model: model.to_string() })
} else {
probe(&root).map(|model| Cfg { base: root, model })
});
}
/// An installed embedding model, by the names people use (bge-m3, nomic-embed-text…).
fn agent(secs: u64) -> ureq::Agent {
ureq::Agent::config_builder().timeout_global(Some(Duration::from_secs(secs))).build().into()
}
fn probe(root: &str) -> Option<String> {
let v: serde_json::Value = agent(3).get(&format!("{root}/api/tags")).call().ok()?.body_mut().read_json().ok()?;
v["models"]
.as_array()?
.iter()
.filter_map(|m| m["name"].as_str())
.find(|n| {
let n = n.to_lowercase();
n.contains("embed") || n.contains("bge") || n.starts_with("m3")
})
.map(String::from)
}
/// Semantic search is on and worth a call right now.
pub fn enabled() -> bool {
CFG.lock().unwrap().as_ref().is_some_and(|c| c.is_some()) && DOWN.lock().unwrap().map(|t| t.elapsed() > Duration::from_secs(120)).unwrap_or(true)
}
/// The chat provider's Ollama address, ready for embedding (the /v1 chat suffix stripped).
pub fn ollama_base(stored: &str) -> String {
let d = crate::providers::ProviderKind::Ollama.default_base_url();
let b = if stored.trim().is_empty() { d.to_string() } else { stored.trim().to_string() };
b.trim_end_matches("/v1").trim_end_matches('/').to_string()
}
pub fn model() -> String {
CFG.lock().unwrap().as_ref().and_then(|c| c.as_ref().map(|c| c.model.clone())).unwrap_or_default()
}
/// One batch of vectors, None when Ollama is not answering (marked down for a while).
/// Ollama's /api/embed takes the whole list at once.
pub fn embed(texts: &[String]) -> Option<Vec<Vec<f32>>> {
if texts.is_empty() {
return Some(vec![]);
}
let (base, model) = CFG.lock().unwrap().as_ref().as_ref().and_then(|c| c.as_ref()).map(|c| (c.base.clone(), c.model.clone()))?;
let body = serde_json::json!({"model": model, "input": texts});
let out: Result<serde_json::Value, String> = agent(20).post(&format!("{base}/api/embed")).send_json(body).and_then(|mut r| r.body_mut().read_json()).map_err(|e| e.to_string());
match out {
Ok(v) => {
*DOWN.lock().unwrap() = None;
let embs = v["embeddings"].as_array()?;
if embs.len() != texts.len() {
return None;
}
Some(embs.iter().map(|e| e.as_array().map(|row| row.iter().filter_map(|x| x.as_f64()).map(|x| x as f32).collect()).unwrap_or_default()).collect())
}
Err(_) => {
*DOWN.lock().unwrap() = Some(Instant::now());
None
}
}
}
/// The slow lane, once per app: index the person's document folders, then keep memory and
/// document vectors filled in small batches. Folders move with the settings.
pub fn start() {
STARTED.call_once(|| {
std::thread::spawn(move || loop {
std::thread::sleep(Duration::from_secs(20));
let folders = crate::app::doc_folders();
crate::docindex::sweep(&folders);
// New memories and new chunks, a batch at a time.
let rows = crate::store::memory_pending(24);
if !rows.is_empty() {
if let Some(vs) = embed(&rows.iter().map(|(_, t)| t.clone()).collect::<Vec<_>>()) {
for ((id, _), v) in rows.iter().zip(vs) {
crate::store::memory_set_vec(*id, &v);
}
}
}
let rows = crate::store::doc_pending(24);
if !rows.is_empty() {
if let Some(vs) = embed(&rows.iter().map(|(_, t)| t.clone()).collect::<Vec<_>>()) {
for ((id, _), v) in rows.iter().zip(vs) {
crate::store::doc_set_vec(*id, &v);
}
}
}
});
});
}
pub fn f32s_to_blob(v: &[f32]) -> Vec<u8> {
let mut b = Vec::with_capacity(v.len() * 4);
for x in v {
b.extend_from_slice(&x.to_le_bytes());
}
b
}
pub fn blob_to_f32s(b: &[u8]) -> Vec<f32> {
b.chunks_exact(4).map(|c| f32::from_le_bytes(c.try_into().unwrap())).collect()
}
/// Cosine, with the guards a shelf of half-filled vectors deserves.
pub fn cosine(a: &[f32], b: &[f32]) -> f32 {
let n = a.len().min(b.len());
let (mut d, mut x, mut y) = (0f32, 0f32, 0f32);
for i in 0..n {
d += a[i] * b[i];
x += a[i] * a[i];
y += b[i] * b[i];
}
if x == 0.0 || y == 0.0 {
return 0.0;
}
d / (x.sqrt() * y.sqrt())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn offline_ollama_is_a_normal_state() {
// No Ollama in CI: embedding says "no", searches keep working with words.
configure("http://127.0.0.1:1/v1", "");
assert!(!enabled(), "a port with nothing behind it means no semantic search");
assert!(embed(&vec!["test".to_string()]).is_none());
// Off is off, and geometry is sane.
configure("http://127.0.0.1:11434", "off");
assert!(!enabled());
let a = vec![1f32, 0.0, 0.0];
let b = vec![0f32, 1.0, 0.0];
assert!(cosine(&a, &b) < 1e-6);
assert!((cosine(&a, &a) - 1.0).abs() < 1e-6);
assert_eq!(blob_to_f32s(&f32s_to_blob(&a)), a);
}
}

View File

@ -53,7 +53,9 @@ mod teams;
mod theme; mod theme;
mod tools; mod tools;
mod triggers; mod triggers;
mod embed;
mod store; mod store;
mod docindex;
mod vault; mod vault;
mod vault_store; mod vault_store;
mod vnc; mod vnc;

View File

@ -328,6 +328,12 @@ pub struct State {
pub providers: std::collections::BTreeMap<String, ProviderSettings>, pub providers: std::collections::BTreeMap<String, ProviderSettings>,
#[serde(default)] #[serde(default)]
pub sidebar_collapsed: bool, pub sidebar_collapsed: bool,
/// 語意搜尋: "" = 找 Ollama 裡裝好的 embedding 模型, "off" = 關。
#[serde(default)]
pub embed_model: String,
/// 文件索引的資料夾; 空 = 用這台電腦的「文件」資料夾。
#[serde(default)]
pub doc_folders: Vec<String>,
#[serde(default = "default_sidebar_width")] #[serde(default = "default_sidebar_width")]
pub sidebar_width: f32, pub sidebar_width: f32,
/// 允許在這台電腦上執行任務,而不只是在它自己的電腦上 (setting `local-execution`, ids fuUvu2/Hd1KB+). /// 允許在這台電腦上執行任務,而不只是在它自己的電腦上 (setting `local-execution`, ids fuUvu2/Hd1KB+).
@ -663,6 +669,8 @@ impl State {
accent: Accent::Black, accent: Accent::Black,
providers: Default::default(), providers: Default::default(),
sidebar_collapsed: false, sidebar_collapsed: false,
embed_model: String::new(),
doc_folders: vec![],
sidebar_width: default_sidebar_width(), sidebar_width: default_sidebar_width(),
local_execution: false, local_execution: false,
remote_computers: vec![], remote_computers: vec![],

View File

@ -70,9 +70,31 @@ fn open() -> Option<Store> {
END; END;
CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
INSERT INTO memories_fts(memories_fts, rowid, text) VALUES ('delete', old.id, old.text); INSERT INTO memories_fts(memories_fts, rowid, text) VALUES ('delete', old.id, old.text);
END;
CREATE TABLE IF NOT EXISTS docs(
path TEXT NOT NULL,
mtime INTEGER NOT NULL,
idx INTEGER NOT NULL,
text TEXT NOT NULL,
vec BLOB,
PRIMARY KEY(path, idx)
);
CREATE VIRTUAL TABLE IF NOT EXISTS docs_fts USING fts5(text, content='docs', content_rowid='rowid', tokenize='trigram');
CREATE TRIGGER IF NOT EXISTS docs_ai AFTER INSERT ON docs BEGIN
INSERT INTO docs_fts(rowid, text) VALUES (new.rowid, new.text);
END;
CREATE TRIGGER IF NOT EXISTS docs_ad AFTER DELETE ON docs BEGIN
INSERT INTO docs_fts(docs_fts, rowid, text) VALUES ('delete', old.rowid, old.text);
END;
CREATE TRIGGER IF NOT EXISTS docs_au AFTER UPDATE ON docs BEGIN
INSERT INTO docs_fts(docs_fts, rowid, text) VALUES ('delete', old.rowid, old.text);
INSERT INTO docs_fts(rowid, text) VALUES (new.rowid, new.text);
END;", END;",
) )
.ok()?; .ok()?;
// Memories gained a vector beside the words (P6 step 2); older bases just get the column.
let _ = c.execute_batch("ALTER TABLE memories ADD COLUMN vec BLOB;");
let cutoff = crate::model::now_ms() - KEEP_DAYS * 86_400_000; let cutoff = crate::model::now_ms() - KEEP_DAYS * 86_400_000;
let _ = c.execute("DELETE FROM audit WHERE at < ?1", params![cutoff]); let _ = c.execute("DELETE FROM audit WHERE at < ?1", params![cutoff]);
Some(Store { conn: Mutex::new(c) }) Some(Store { conn: Mutex::new(c) })
@ -374,7 +396,7 @@ pub fn recall(bot: u64, query: &str, limit: usize) -> Vec<String> {
let _ = c.prepare("SELECT id, created, text FROM memories WHERE bot IN (?1, 0) AND text LIKE '%'||?2||'%' LIMIT 60").and_then(|mut st| { let _ = c.prepare("SELECT id, created, text FROM memories WHERE bot IN (?1, 0) AND text LIKE '%'||?2||'%' LIMIT 60").and_then(|mut st| {
let it = st.query_map(params![bot, seg], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?, r.get::<_, String>(2)?)))?; let it = st.query_map(params![bot, seg], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?, r.get::<_, String>(2)?)))?;
for x in it.flatten() { for x in it.flatten() {
let (id, created, text) = x; let (_id, created, text) = x;
match scored.iter_mut().find(|(_, _, t)| t == &text) { match scored.iter_mut().find(|(_, _, t)| t == &text) {
Some(e) => e.0 += 1, Some(e) => e.0 += 1,
None => scored.push((1, created, text)), None => scored.push((1, created, text)),
@ -390,6 +412,17 @@ pub fn recall(bot: u64, query: &str, limit: usize) -> Vec<String> {
} }
} }
} }
// The semantic side (P6 step 2): vectors of the same shelf, when the person's Ollama
// has an embedding model. Reciprocal-rank merge: a line both words and meaning rank
// high goes first; with no Ollama this whole arm is simply absent.
if crate::embed::enabled() {
if let Some(v) = crate::embed::embed(&[query.to_string()]).and_then(|mut v| v.pop()) {
let mut ranked: Vec<(f32, String)> = memory_vecs_of(bot).into_iter().map(|(t, w)| (crate::embed::cosine(&v, &w), t)).filter(|(sc, _)| *sc > 0.30).collect();
ranked.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
let vr: Vec<String> = ranked.into_iter().map(|(_, t)| t).take(limit * 2).collect();
out = rrf(&[vr, out], limit * 2);
}
}
if out.is_empty() { if out.is_empty() {
// Nothing matched: the freshest facts, so "記住…" works in the very next chat even // Nothing matched: the freshest facts, so "記住…" works in the very next chat even
// with no words to match. // with no words to match.
@ -407,3 +440,196 @@ pub fn recall(bot: u64, query: &str, limit: usize) -> Vec<String> {
out.truncate(limit); out.truncate(limit);
out out
} }
/// Reciprocal-rank fusion of ranked lists (words, meaning): a line two lists like beats a
/// line one list loves. k=60 is the usual calm setting.
fn rrf(lists: &[Vec<String>], limit: usize) -> Vec<String> {
let mut scored: Vec<(f64, String)> = vec![];
for list in lists {
for (rank, item) in list.iter().enumerate() {
let add = 1.0 / (60.0 + rank as f64);
match scored.iter_mut().find(|(_, t)| t == item) {
Some(e) => e.0 += add,
None => scored.push((add, item.clone())),
}
}
}
scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
scored.into_iter().take(limit).map(|(_, t)| t).collect()
}
// ---------- 向量與文件 (P6 step 2) ----------
/// Memory rows the background thread has not embedded yet.
pub fn memory_pending(limit: usize) -> Vec<(i64, String)> {
let Some(s) = store() else { return vec![] };
let Ok(c) = s.conn.lock() else { return vec![] };
let mut out = vec![];
let _ = c.prepare("SELECT id, text FROM memories WHERE vec IS NULL ORDER BY created DESC LIMIT ?1").and_then(|mut st| {
for x in st.query_map(params![limit as i64], |r| Ok((r.get(0)?, r.get(1)?)))?.flatten() {
out.push(x);
}
Ok(())
});
out
}
pub fn memory_set_vec(id: i64, v: &[f32]) {
let Some(s) = store() else { return };
let Ok(c) = s.conn.lock() else { return };
let _ = c.execute("UPDATE memories SET vec=?1 WHERE id=?2", params![crate::embed::f32s_to_blob(v), id]);
}
fn memory_vecs_of(bot: i64) -> Vec<(String, Vec<f32>)> {
let Some(s) = store() else { return vec![] };
let Ok(c) = s.conn.lock() else { return vec![] };
let mut out = vec![];
let _ = c.prepare("SELECT text, vec FROM memories WHERE bot IN (?1, 0) AND vec IS NOT NULL LIMIT 5000").and_then(|mut st| {
for x in st.query_map(params![bot], |r| Ok((r.get::<_, String>(0)?, r.get::<_, Vec<u8>>(1)?)))?.flatten() {
out.push((x.0, crate::embed::blob_to_f32s(&x.1)));
}
Ok(())
});
out
}
/// The sweep's cheap change check: the file is indexed and unmodified.
pub fn doc_mtime_is(path: &str, mtime: i64) -> bool {
let Some(s) = store() else { return false };
let Ok(c) = s.conn.lock() else { return false };
c.query_row("SELECT COUNT(*) FROM docs WHERE path=?1 AND mtime=?2", params![path, mtime], |r| r.get::<_, i64>(0)).map(|n| n > 0).unwrap_or(false)
}
/// A file's chunks, replacing the old ones (the index of a file is exactly its current self).
pub fn doc_replace(path: &str, mtime: i64, chunks: &[String]) {
let Some(s) = store() else { return };
let Ok(c) = s.conn.lock() else { return };
let _ = c.execute("DELETE FROM docs WHERE path=?1", params![path]);
for (i, ch) in chunks.iter().enumerate() {
let _ = c.execute("INSERT INTO docs(path,mtime,idx,text) VALUES(?1,?2,?3,?4)", params![path, mtime, i as i64, ch]);
}
}
pub fn doc_remove(path: &str) {
let Some(s) = store() else { return };
let Ok(c) = s.conn.lock() else { return };
let _ = c.execute("DELETE FROM docs WHERE path=?1", params![path]);
}
pub fn doc_mtimes() -> Vec<(String, i64)> {
let Some(s) = store() else { return vec![] };
let Ok(c) = s.conn.lock() else { return vec![] };
let mut out = vec![];
let _ = c.prepare("SELECT DISTINCT path, mtime FROM docs").and_then(|mut st| {
for x in st.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))?.flatten() {
out.push(x);
}
Ok(())
});
out
}
/// Chunks the background thread has not embedded yet.
pub fn doc_pending(limit: usize) -> Vec<(i64, String)> {
let Some(s) = store() else { return vec![] };
let Ok(c) = s.conn.lock() else { return vec![] };
let mut out = vec![];
let _ = c.prepare("SELECT rowid, text FROM docs WHERE vec IS NULL LIMIT ?1").and_then(|mut st| {
for x in st.query_map(params![limit as i64], |r| Ok((r.get(0)?, r.get(1)?)))?.flatten() {
out.push(x);
}
Ok(())
});
out
}
pub fn doc_set_vec(rowid: i64, v: &[f32]) {
let Some(s) = store() else { return };
let Ok(c) = s.conn.lock() else { return };
let _ = c.execute("UPDATE docs SET vec=?1 WHERE rowid=?2", params![crate::embed::f32s_to_blob(v), rowid]);
}
/// 「我上個月那份報表」: chunks of the person's indexed files, words first (FTS, then
/// two-character windows for Chinese), meaning when the local model is there.
pub fn docs_search(query: &str, limit: usize) -> Vec<(String, String)> {
let mut words: Vec<(String, String)> = vec![]; // (path, chunk), best first, no doubles
let Some(s) = store() else { return words };
let Ok(c) = s.conn.lock() else { return words };
let q: String = query.chars().filter(|ch| *ch != '"').collect::<String>().split_whitespace().collect::<Vec<_>>().join(" ");
if q.chars().count() >= 3 {
let phrase = format!("\"{q}\"");
let _ = c.prepare("SELECT d.path, d.text FROM docs d JOIN docs_fts f ON f.rowid=d.rowid WHERE docs_fts MATCH ?1 ORDER BY bm25(docs_fts) LIMIT ?2").and_then(|mut st| {
for x in st.query_map(params![phrase, limit as i64 * 2], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?.flatten() {
if !words.contains(&x) {
words.push(x);
}
}
Ok(())
});
}
// Two-character windows, scored like the memory shelf.
let mut segs: Vec<String> = vec![];
for w in q.split_whitespace() {
let ch: Vec<char> = w.chars().collect();
if w.is_ascii() {
if ch.len() >= 2 {
segs.push(w.to_lowercase());
}
} else if ch.len() >= 2 {
for i in 0..ch.len() - 1 {
segs.push(format!("{}{}", ch[i], ch[i + 1]));
}
}
}
segs.sort();
segs.dedup();
segs.truncate(12);
if !segs.is_empty() {
let mut scored: Vec<(usize, String, String)> = vec![];
for seg in &segs {
let _ = c.prepare("SELECT path, text FROM docs WHERE text LIKE '%'||?1||'%' LIMIT 80").and_then(|mut st| {
for x in st.query_map(params![seg], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?.flatten() {
match scored.iter_mut().find(|(_, p, t)| p == &x.0 && t == &x.1) {
Some(e) => e.0 += 1,
None => scored.push((1, x.0, x.1)),
}
}
Ok(())
});
}
scored.sort_by(|a, b| b.0.cmp(&a.0));
for (_, path, text) in scored {
if !words.iter().any(|(p, t)| p == &path && t == &text) {
words.push((path, text));
}
}
}
if crate::embed::enabled() {
if let Some(v) = crate::embed::embed(&[query.to_string()]).and_then(|mut v| v.pop()) {
let mut ranked: Vec<(f32, String, String)> = vec![];
let _ = c.prepare("SELECT path, text, vec FROM docs WHERE vec IS NOT NULL LIMIT 8000").and_then(|mut st| {
for x in st.query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?, r.get::<_, Vec<u8>>(2)?)))?.flatten() {
let sc = crate::embed::cosine(&v, &crate::embed::blob_to_f32s(&x.2));
if sc > 0.30 {
ranked.push((sc, x.0, x.1));
}
}
Ok(())
});
ranked.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
let keys: Vec<String> = words.iter().map(|(p, t)| format!("{p}\u{1}{t}")).collect();
let vr: Vec<String> = ranked.into_iter().map(|(_, p, t)| format!("{p}\u{1}{t}")).take(limit * 2).collect();
let merged = rrf(&[vr, keys], limit * 2);
let mut out: Vec<(String, String)> = vec![];
for k in merged {
let mut sp = k.splitn(2, '\u{1}');
if let (Some(p), Some(t)) = (sp.next(), sp.next()) {
out.push((p.to_string(), t.to_string()));
}
}
return out.into_iter().take(limit).collect();
}
}
words.truncate(limit);
words
}

View File

@ -39,6 +39,10 @@ pub fn specs() -> Vec<Value> {
vec![ vec![
json!({"name": "list_dir", "description": "List the files and folders in a folder (relative to the work folder unless absolute).", json!({"name": "list_dir", "description": "List the files and folders in a folder (relative to the work folder unless absolute).",
"parameters": {"type": "object", "properties": {"path": {"type": "string", "description": "Folder to list; empty means the work folder."}}, "required": []}}), "parameters": {"type": "object", "properties": {"path": {"type": "string", "description": "Folder to list; empty means the work folder."}}, "required": []}}),
json!({"name": "search_documents", "description": "In the person's own indexed document folders (their 設定 › 一般 › 文件索引), find the passages that answer a question, by meaning as well as words. Use it for 「我上個月那份報表在哪」, 「我寫過的語法筆記」 — questions about THEIR files, not about the computer. Returns the file path and the text of each match.",
"parameters": {"type": "object", "properties": {
"query": {"type": "string", "description": "What to look for, in a sentence."},
"limit": {"type": "integer", "description": "How many passages (default 6)."}}, "required": ["query"]}}),
json!({"name": "search", "description": "Find text in files under a folder. Returns matching lines as path:line:text.", json!({"name": "search", "description": "Find text in files under a folder. Returns matching lines as path:line:text.",
"parameters": {"type": "object", "properties": { "parameters": {"type": "object", "properties": {
"pattern": {"type": "string", "description": "Text to look for (regular expression when ripgrep is installed)."}, "pattern": {"type": "string", "description": "Text to look for (regular expression when ripgrep is installed)."},
@ -70,6 +74,7 @@ pub fn label(name: &str) -> &'static str {
match name { match name {
HANDOFF_TOOL => "請你接手電腦", HANDOFF_TOOL => "請你接手電腦",
"list_dir" => "查看資料夾", "list_dir" => "查看資料夾",
"search_documents" => "翻你的文件",
"search" => "搜尋檔案", "search" => "搜尋檔案",
"read_file" => "讀取檔案", "read_file" => "讀取檔案",
"edit_file" => "修改檔案", "edit_file" => "修改檔案",
@ -1188,6 +1193,7 @@ pub fn run(name: &str, args: &str, workdir: &Path, cancel: &AtomicBool, ids: &Ru
let _ = std::fs::create_dir_all(workdir); let _ = std::fs::create_dir_all(workdir);
let r = match name { let r = match name {
"list_dir" => list_dir(&resolve(workdir, &arg_str(&v, "path"))), "list_dir" => list_dir(&resolve(workdir, &arg_str(&v, "path"))),
"search_documents" => docs_search_tool(&v),
"search" => search(&arg_str(&v, "pattern"), &resolve(workdir, &arg_str(&v, "path")), v.get("files_only").and_then(Value::as_bool).unwrap_or(false)), "search" => search(&arg_str(&v, "pattern"), &resolve(workdir, &arg_str(&v, "path")), v.get("files_only").and_then(Value::as_bool).unwrap_or(false)),
"read_file" => read_file( "read_file" => read_file(
&resolve(workdir, &arg_str(&v, "path")), &resolve(workdir, &arg_str(&v, "path")),
@ -1206,6 +1212,24 @@ pub fn run(name: &str, args: &str, workdir: &Path, cancel: &AtomicBool, ids: &Ru
} }
} }
/// 翻你的文件: the indexed folders, by meaning and words (store::docs_search).
fn docs_search_tool(v: &Value) -> Result<String, String> {
let q = v.get("query").and_then(Value::as_str).unwrap_or("").trim();
if q.is_empty() {
return Err("請給一句要找的話。".to_string());
}
let limit = v.get("limit").and_then(Value::as_u64).unwrap_or(6).clamp(1, 20) as usize;
let hits = crate::store::docs_search(q, limit);
if hits.is_empty() {
return Err("在你的文件資料夾裡找不到相關的內容(可以在 設定 › 一般 › 文件索引 加入資料夾)。".to_string());
}
let mut out = String::new();
for (path, chunk) in hits {
out.push_str(&format!("【{path}】\n{}\n\n", chunk.chars().take(600).collect::<String>()));
}
Ok(out.trim().to_string())
}
fn list_dir(p: &Path) -> Result<String, String> { fn list_dir(p: &Path) -> Result<String, String> {
let rd = std::fs::read_dir(p).map_err(|e| format!("無法開啟資料夾 {}:{e}", p.display()))?; let rd = std::fs::read_dir(p).map_err(|e| format!("無法開啟資料夾 {}:{e}", p.display()))?;
let mut rows: Vec<(bool, String, u64)> = rd let mut rows: Vec<(bool, String, u64)> = rd