Texture caches capped; big previews decode off the UI thread; helper processes reaped
This commit is contained in:
parent
21b917b0eb
commit
7391e3a101
24
src/app.rs
24
src/app.rs
|
|
@ -943,6 +943,9 @@ impl App {
|
||||||
for id in std::mem::take(&mut self.group_next) {
|
for id in std::mem::take(&mut self.group_next) {
|
||||||
if !self.streams.contains_key(&id) {
|
if !self.streams.contains_key(&id) {
|
||||||
self.start_reply(ctx, id);
|
self.start_reply(ctx, id);
|
||||||
|
} else {
|
||||||
|
// Still busy: keep the turn queued instead of dropping it with the take().
|
||||||
|
self.group_next.push(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (id, texts) in std::mem::take(&mut self.pending_turns) {
|
for (id, texts) in std::mem::take(&mut self.pending_turns) {
|
||||||
|
|
@ -951,7 +954,13 @@ impl App {
|
||||||
b.messages.push(Message::user(&t));
|
b.messages.push(Message::user(&t));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.start_reply(ctx, id);
|
if self.streams.contains_key(&id) {
|
||||||
|
// A turn just settled in the same tick we queued this one; starting now would
|
||||||
|
// replace the running Live and let two agent threads write one chat. Retry idle.
|
||||||
|
self.group_next.push(id);
|
||||||
|
} else {
|
||||||
|
self.start_reply(ctx, id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1239,9 +1248,11 @@ impl App {
|
||||||
StreamEvent::Steered(m) => b.messages.push(m),
|
StreamEvent::Steered(m) => b.messages.push(m),
|
||||||
StreamEvent::NextStep(via) => b.messages.push(Message::assistant("", &via)),
|
StreamEvent::NextStep(via) => b.messages.push(Message::assistant("", &via)),
|
||||||
StreamEvent::ToolResult(m) => b.messages.push(m),
|
StreamEvent::ToolResult(m) => b.messages.push(m),
|
||||||
StreamEvent::Compacted(n, summary) => {
|
StreamEvent::Compacted(n, summary, boundary) => {
|
||||||
let n = n.min(b.messages.len());
|
// The model's history can be shorter than the window's chat (group chats
|
||||||
b.messages.splice(..n, [summary]);
|
// filter); land the cut at the real first-kept message, not the raw index.
|
||||||
|
let cut = b.messages.iter().position(|m| m.at == boundary && m.role == Role::User).unwrap_or(n.min(b.messages.len()));
|
||||||
|
b.messages.splice(..cut, [summary]);
|
||||||
}
|
}
|
||||||
StreamEvent::ToolStatus(call_id, st) => {
|
StreamEvent::ToolStatus(call_id, st) => {
|
||||||
if let Some(c) = b.messages.iter_mut().rev().flat_map(|m| m.tool_calls.iter_mut()).find(|c| c.id == call_id) {
|
if let Some(c) = b.messages.iter_mut().rev().flat_map(|m| m.tool_calls.iter_mut()).find(|c| c.id == call_id) {
|
||||||
|
|
@ -2262,7 +2273,7 @@ impl App {
|
||||||
crate::icons::label(ui, crate::icons::Icon::Check, &t("輔助使用已打開"), 11.5, p.text3);
|
crate::icons::label(ui, crate::icons::Icon::Check, &t("輔助使用已打開"), 11.5, p.text3);
|
||||||
} else if ui.small_button(t("打開輔助使用")).on_hover_text(t("macOS 要你允許一次,AnyBot 才看得到你的瀏覽器視窗")).clicked() {
|
} else if ui.small_button(t("打開輔助使用")).on_hover_text(t("macOS 要你允許一次,AnyBot 才看得到你的瀏覽器視窗")).clicked() {
|
||||||
if !crate::axread::trusted(true) {
|
if !crate::axread::trusted(true) {
|
||||||
let _ = std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility").spawn();
|
let _ = crate::computer::spawn_reaped(&mut std::process::Command::new("open").arg("x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|
@ -3238,7 +3249,8 @@ impl App {
|
||||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||||
}
|
}
|
||||||
let secs = t0.elapsed().as_secs();
|
let secs = t0.elapsed().as_secs();
|
||||||
let msgs = app.state.bot(id).map(|b| b.messages[start..].to_vec()).unwrap_or_default();
|
// The chat can shrink (compaction) between the snapshot and now: slice safely.
|
||||||
|
let msgs = app.state.bot(id).and_then(|b| b.messages.get(start..)).map(|m| m.to_vec()).unwrap_or_default();
|
||||||
let clip = |s: &str, n: usize| -> String {
|
let clip = |s: &str, n: usize| -> String {
|
||||||
let one = s.replace('\n', " ⏎ ");
|
let one = s.replace('\n', " ⏎ ");
|
||||||
if one.chars().count() > n { format!("{}…", one.chars().take(n).collect::<String>()) } else { one }
|
if one.chars().count() > n { format!("{}…", one.chars().take(n).collect::<String>()) } else { one }
|
||||||
|
|
|
||||||
|
|
@ -96,6 +96,18 @@ fn start_job(kind: &str, path: &str) -> Option<Arc<Mutex<PageJob>>> {
|
||||||
g.done = true;
|
g.done = true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
"image" => {
|
||||||
|
// Decoding (a 6000×4000 photo, a big SVG) on the UI thread froze the window;
|
||||||
|
// the viewer shows a spinner until this thread is done.
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let img = preview::decode_image(&path);
|
||||||
|
let mut g = j.lock().unwrap();
|
||||||
|
if let Some(img) = img {
|
||||||
|
g.ready.push(img);
|
||||||
|
}
|
||||||
|
g.done = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
"video" => {
|
"video" => {
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let out = std::env::temp_dir().join(format!("anybot-poster-{}-{}.png", std::process::id(), crate::model::now_ms()));
|
let out = std::env::temp_dir().join(format!("anybot-poster-{}-{}.png", std::process::id(), crate::model::now_ms()));
|
||||||
|
|
@ -258,8 +270,30 @@ impl App {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Preview::Image => {
|
Preview::Image => {
|
||||||
|
// The picture is decoded on a worker thread (start_job "image").
|
||||||
if fv.image.is_none() {
|
if fv.image.is_none() {
|
||||||
fv.image = load_texture(ui.ctx(), &fv.path);
|
if let Some(job) = fv.job.clone() {
|
||||||
|
let (imgs, done) = {
|
||||||
|
let mut g = job.lock().unwrap();
|
||||||
|
(std::mem::take(&mut g.ready), g.done)
|
||||||
|
};
|
||||||
|
match imgs.into_iter().next() {
|
||||||
|
Some(img) => fv.image = Some(texture(ui.ctx(), "file-view", &img)),
|
||||||
|
None => {
|
||||||
|
ui.add_space(40.0);
|
||||||
|
ui.vertical_centered(|ui| {
|
||||||
|
if done {
|
||||||
|
ui.label(RichText::new(t("這張圖片打不開")).color(p.text3));
|
||||||
|
} else {
|
||||||
|
ui.spinner();
|
||||||
|
ui.ctx().request_repaint_after(std::time::Duration::from_millis(80));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
fv.image = load_texture(ui.ctx(), &fv.path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
match &fv.image {
|
match &fv.image {
|
||||||
Some(tex) => {
|
Some(tex) => {
|
||||||
|
|
|
||||||
|
|
@ -777,6 +777,12 @@ impl App {
|
||||||
let dark = p.pane != LIGHT.pane;
|
let dark = p.pane != LIGHT.pane;
|
||||||
let key = key_of(src, dark);
|
let key = key_of(src, dark);
|
||||||
if !self.md.diagrams.contains_key(&key) {
|
if !self.md.diagrams.contains_key(&key) {
|
||||||
|
// Keep a long chat from growing forever: every diagram is a GPU texture. Past a
|
||||||
|
// roomy cap, drop the whole cache — the diagrams shown again redraw on a worker
|
||||||
|
// thread (a brief spinner beats slowly eating hundreds of MBs).
|
||||||
|
if self.md.diagrams.len() >= 64 {
|
||||||
|
self.md.diagrams.clear();
|
||||||
|
}
|
||||||
let slot = Arc::new(Mutex::new(None));
|
let slot = Arc::new(Mutex::new(None));
|
||||||
let (s2, src2) = (slot.clone(), src.to_string());
|
let (s2, src2) = (slot.clone(), src.to_string());
|
||||||
let ctx = ui.ctx().clone();
|
let ctx = ui.ctx().clone();
|
||||||
|
|
|
||||||
|
|
@ -587,8 +587,11 @@ impl App {
|
||||||
VaultDialog::Import(mut st) => {
|
VaultDialog::Import(mut st) => {
|
||||||
if self.vault_import(ctx, &mut st) {
|
if self.vault_import(ctx, &mut st) {
|
||||||
next = Some(VaultDialog::Import(st));
|
next = Some(VaultDialog::Import(st));
|
||||||
|
} else {
|
||||||
|
// Only when it closed: marking saved every frame reloaded the whole
|
||||||
|
// vault list on every repaint while the import dialog was open.
|
||||||
|
saved = true;
|
||||||
}
|
}
|
||||||
saved = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.vault.dialog = next;
|
self.vault.dialog = next;
|
||||||
|
|
|
||||||
|
|
@ -250,13 +250,19 @@ fn rasterize(icon: Icon, px: u32) -> Option<egui::ColorImage> {
|
||||||
pub fn paint(p: &Painter, rect: Rect, icon: Icon, col: Color32) {
|
pub fn paint(p: &Painter, rect: Rect, icon: Icon, col: Color32) {
|
||||||
let ppp = p.ctx().pixels_per_point();
|
let ppp = p.ctx().pixels_per_point();
|
||||||
let side = rect.width().min(rect.height());
|
let side = rect.width().min(rect.height());
|
||||||
let px = (side * ppp).round().max(8.0) as u32;
|
// Quantised size (2 px steps, capped): a resized window used to create a new texture for
|
||||||
|
// every pixel width, so the cache grew without limit while the window was dragged.
|
||||||
|
let px = ((side * ppp).round().max(8.0) as u32 / 2 * 2).clamp(8, 512);
|
||||||
let tex = {
|
let tex = {
|
||||||
let mut c = cache().lock().unwrap();
|
let mut c = cache().lock().unwrap();
|
||||||
match c.get(&(icon, px)) {
|
match c.get(&(icon, px)) {
|
||||||
Some(t) => t.clone(),
|
Some(t) => t.clone(),
|
||||||
None => {
|
None => {
|
||||||
let Some(img) = rasterize(icon, px) else { return };
|
let Some(img) = rasterize(icon, px) else { return };
|
||||||
|
// Roomy hard cap: past it, start the cache fresh (textures reload on demand).
|
||||||
|
if c.len() >= 1500 {
|
||||||
|
c.clear();
|
||||||
|
}
|
||||||
let t = p.ctx().load_texture(format!("icon-{icon:?}-{px}"), img, egui::TextureOptions::LINEAR);
|
let t = p.ctx().load_texture(format!("icon-{icon:?}-{px}"), img, egui::TextureOptions::LINEAR);
|
||||||
c.insert((icon, px), t.clone());
|
c.insert((icon, px), t.clone());
|
||||||
t
|
t
|
||||||
|
|
|
||||||
|
|
@ -90,6 +90,7 @@ pub fn screensaver_active() -> bool {
|
||||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||||
}
|
}
|
||||||
let _ = c.kill();
|
let _ = c.kill();
|
||||||
|
let _ = c.wait();
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -116,10 +117,10 @@ pub fn banner(title: &str, body: &str) {
|
||||||
if !cfg!(target_os = "macos") {
|
if !cfg!(target_os = "macos") {
|
||||||
// The desktop's notification service (GNOME, KDE, XFCE…) through notify-send.
|
// The desktop's notification service (GNOME, KDE, XFCE…) through notify-send.
|
||||||
let body: String = body.chars().take(180).collect();
|
let body: String = body.chars().take(180).collect();
|
||||||
let _ = Command::new("notify-send").args(["-a", "AnyBot", title, &body]).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn();
|
let _ = crate::computer::spawn_reaped(&mut Command::new("notify-send").args(["-a", "AnyBot", title, &body]).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let _ = Command::new("osascript").arg("-e").arg(banner_script(title, body)).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).spawn();
|
let _ = crate::computer::spawn_reaped(&mut Command::new("osascript").arg("-e").arg(banner_script(title, body)).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,8 @@ pub fn system_open(target: impl AsRef<std::ffi::OsStr>) -> std::process::Command
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn open_with_system(target: &str) {
|
pub fn open_with_system(target: &str) {
|
||||||
if let Err(e) = system_open(target).spawn() {
|
let mut c = system_open(target);
|
||||||
|
if let Err(e) = crate::computer::spawn_reaped(&mut c) {
|
||||||
eprintln!("anybot: couldn't open {target}: {e}");
|
eprintln!("anybot: couldn't open {target}: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -138,7 +139,13 @@ fn clip(t: String) -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn read_text(path: &Path) -> Result<String, String> {
|
fn read_text(path: &Path) -> Result<String, String> {
|
||||||
let bytes = std::fs::read(path).map_err(|e| format!("讀不到檔案:{e}"))?;
|
use std::io::Read;
|
||||||
|
// Read only the head of the file: a preview never shows more than clip() keeps (200 K
|
||||||
|
// chars), and reading a multi-GB log whole on the UI thread froze the whole window.
|
||||||
|
const HEAD: u64 = 4 << 20;
|
||||||
|
let mut f = std::fs::File::open(path).map_err(|e| format!("讀不到檔案:{e}"))?;
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
f.by_ref().take(HEAD).read_to_end(&mut bytes).map_err(|e| format!("讀不到檔案:{e}"))?;
|
||||||
if bytes.iter().take(8000).any(|b| *b == 0) {
|
if bytes.iter().take(8000).any(|b| *b == 0) {
|
||||||
return Err("這是二進位檔案,沒辦法當文字顯示。".into());
|
return Err("這是二進位檔案,沒辦法當文字顯示。".into());
|
||||||
}
|
}
|
||||||
|
|
@ -456,15 +463,36 @@ fn md_table(rows: &[Vec<String>]) -> String {
|
||||||
s
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Find `<tag` followed by `>` or a space, ASCII case-insensitive, on the original string.
|
||||||
|
/// (Lowercasing the whole page first shifts byte offsets — "İ" becomes 3 bytes when lowered —
|
||||||
|
/// and cutting the original at those offsets panics.)
|
||||||
|
fn find_open_tag(s: &str, tag: &str) -> Option<usize> {
|
||||||
|
let (b, t) = (s.as_bytes(), tag.as_bytes());
|
||||||
|
(0..b.len()).find(|&i| {
|
||||||
|
b[i] == b'<'
|
||||||
|
&& b.len() >= i + 1 + t.len()
|
||||||
|
&& b[i + 1..].iter().zip(t).all(|(a, c)| a.eq_ignore_ascii_case(c))
|
||||||
|
&& matches!(b.get(i + 1 + t.len()), Some(c) if *c == b'>' || c.is_ascii_whitespace())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `needle` (ASCII) in `hay`, ASCII case-insensitive, as a byte offset.
|
||||||
|
fn find_ci(hay: &str, needle: &str) -> Option<usize> {
|
||||||
|
let (h, n) = (hay.as_bytes(), needle.as_bytes());
|
||||||
|
if n.is_empty() || h.len() < n.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
(0..=h.len() - n.len()).find(|&i| h[i..i + n.len()].iter().zip(n).all(|(a, c)| a.eq_ignore_ascii_case(c)))
|
||||||
|
}
|
||||||
|
|
||||||
/// A web page as Markdown: headings, paragraphs, lists, tables, bold, links ("text (url)").
|
/// A web page as Markdown: headings, paragraphs, lists, tables, bold, links ("text (url)").
|
||||||
/// Scripts, styles and the <head> are dropped.
|
/// Scripts, styles and the <head> are dropped.
|
||||||
pub fn html_markdown(html: &str) -> String {
|
pub fn html_markdown(html: &str) -> String {
|
||||||
let mut s = html.to_string();
|
let mut s = html.to_string();
|
||||||
for tag in ["head", "script", "style", "noscript", "svg", "template"] {
|
for tag in ["head", "script", "style", "noscript", "svg", "template"] {
|
||||||
loop {
|
loop {
|
||||||
let low = s.to_lowercase();
|
let Some(a) = find_open_tag(&s, tag) else { break };
|
||||||
let Some(a) = low.find(&format!("<{tag}")).filter(|a| low[a + tag.len() + 1..].starts_with(|c: char| c == '>' || c.is_whitespace())) else { break };
|
let end = find_ci(&s[a..], &format!("</{tag}>")).map(|e| a + e + tag.len() + 3).unwrap_or(s.len());
|
||||||
let end = low[a..].find(&format!("</{tag}>")).map(|e| a + e + tag.len() + 3).unwrap_or(s.len());
|
|
||||||
s.replace_range(a..end, "");
|
s.replace_range(a..end, "");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -586,7 +614,12 @@ pub fn html_markdown(html: &str) -> String {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn load_sheet(path: &Path, name: &str) -> Result<Preview, String> {
|
fn load_sheet(path: &Path, name: &str) -> Result<Preview, String> {
|
||||||
|
// calamine parses the whole workbook on the UI thread: a huge .xlsx froze the window.
|
||||||
|
// (CSV/TSV are fine: read_text reads only the head.)
|
||||||
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
|
let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
|
||||||
|
if matches!(ext.as_str(), "xlsx" | "xls" | "ods") && std::fs::metadata(path).map(|m| m.len() > 50 << 20).unwrap_or(false) {
|
||||||
|
return Err("這個試算表太大(超過 50 MB),在 AnyBot 裡預覽會卡住;請用其他 App 打開。".into());
|
||||||
|
}
|
||||||
if ext == "csv" || ext == "tsv" {
|
if ext == "csv" || ext == "tsv" {
|
||||||
let text = read_text(path)?;
|
let text = read_text(path)?;
|
||||||
let sep = if ext == "tsv" { '\t' } else { ',' };
|
let sep = if ext == "tsv" { '\t' } else { ',' };
|
||||||
|
|
@ -623,6 +656,11 @@ fn load_sheet(path: &Path, name: &str) -> Result<Preview, String> {
|
||||||
|
|
||||||
/// An Excel date number (days since 1899-12-30) as "2026-10-31" or "2026-10-29 20:13".
|
/// An Excel date number (days since 1899-12-30) as "2026-10-31" or "2026-10-29 20:13".
|
||||||
pub fn excel_date(v: f64) -> String {
|
pub fn excel_date(v: f64) -> String {
|
||||||
|
// A cell formatted as a date can hold any number (Excel cells go to 1e308); beyond any
|
||||||
|
// real date, show the number — the date maths would overflow chrono and panic.
|
||||||
|
if !v.is_finite() || v.abs() > 3_000_000.0 {
|
||||||
|
return if v.fract().abs() < 0.000001 { format!("{}", v as i64) } else { format!("{v}") };
|
||||||
|
}
|
||||||
let base = chrono::NaiveDate::from_ymd_opt(1899, 12, 30).unwrap().and_hms_opt(0, 0, 0).unwrap();
|
let base = chrono::NaiveDate::from_ymd_opt(1899, 12, 30).unwrap().and_hms_opt(0, 0, 0).unwrap();
|
||||||
let t = base + chrono::Duration::milliseconds((v * 86_400_000.0).round() as i64);
|
let t = base + chrono::Duration::milliseconds((v * 86_400_000.0).round() as i64);
|
||||||
if v.fract().abs() < 1e-9 {
|
if v.fract().abs() < 1e-9 {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue