Show a replayed session's images instead of dropping them
An imported session showed no screenshots. `text_of` kept only `text` blocks, so every image in the replayed tail was silently discarded -- while the *live* translator has always saved them into the session's `files/` and referenced them. Two readings of the same records, and the one used for history was the lesser. `save_image` moves out of `Translator` to a free function both paths call, since the naming scheme for that directory should exist once. `events_from` now takes the session directory to write into, which means the conversion has to happen where that directory exists -- so `Seed` carries the raw JSONL and `launch` turns it into events, rather than `routes` doing it before the session is created. Costs nothing in tokens, which is the point worth recording: this writes into ai-app's own session directory and the phone fetches a reference only when it draws one. Nothing here is ever written to the CLI's stdin -- it reads its own session file, and the only things this app sends it are typed messages, control requests and `/compact`. Verified against the 133 MB session behind the 2026-08-29 incident: 45 images in the replayed tail, written as real PNGs and served over the files route, with the transcript itself staying at 756 KB of references. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VETa8afmpWaYezLCqJhDB8
This commit is contained in:
1 parent
d96bc041a7
commit
d7c692a4ec
6 files changed
+148
-50
No files matched your search
@@ -13,7 +13,7 @@
|
||||
//! without a process.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -307,7 +307,7 @@ impl Translator {
|
||||
}
|
||||
}
|
||||
Some("image") => {
|
||||
if let Some(name) = self.save_image(part) {
|
||||
if let Some(name) = save_image(&self.session_dir, part) {
|
||||
events.push(Event::Image { image: name });
|
||||
}
|
||||
}
|
||||
@@ -328,33 +328,39 @@ impl Translator {
|
||||
}
|
||||
events
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes one base64 image block into `files/` and returns its ref.
|
||||
fn save_image(&self, part: &Value) -> Option<String> {
|
||||
let source = part.get("source")?;
|
||||
let data = source.get("data")?.as_str()?;
|
||||
use base64::Engine;
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(data)
|
||||
.ok()?;
|
||||
// Screenshots are the overwhelming case, and they are PNG; an
|
||||
// unrecognized type is more likely a dialect change than a JPEG.
|
||||
let extension = source
|
||||
.get("media_type")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(crate::media::extension_for)
|
||||
.unwrap_or("png");
|
||||
let name = format!("{}.{extension}", super::super::random_hex());
|
||||
let dir = self.session_dir.join("files");
|
||||
if let Err(err) = wg_app_link::private::create_dir(&dir)
|
||||
.map_err(std::io::Error::other)
|
||||
.and_then(|()| std::fs::write(dir.join(&name), bytes))
|
||||
{
|
||||
tracing::error!("couldn't save produced image: {err}");
|
||||
return None;
|
||||
}
|
||||
Some(name)
|
||||
/// Decodes one base64 image block into `files/` and returns its ref.
|
||||
///
|
||||
/// A free function rather than a method because the import replay needs
|
||||
/// exactly this too: a session's history carries the same image blocks as
|
||||
/// its live output, and a reader who can see a screenshot while it happens
|
||||
/// should still see it after a restart. Two copies of this would be two
|
||||
/// naming schemes for one directory.
|
||||
pub(in crate::session) fn save_image(session_dir: &Path, part: &Value) -> Option<String> {
|
||||
let source = part.get("source")?;
|
||||
let data = source.get("data")?.as_str()?;
|
||||
use base64::Engine;
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(data)
|
||||
.ok()?;
|
||||
// Screenshots are the overwhelming case, and they are PNG; an
|
||||
// unrecognized type is more likely a dialect change than a JPEG.
|
||||
let extension = source
|
||||
.get("media_type")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(crate::media::extension_for)
|
||||
.unwrap_or("png");
|
||||
let name = format!("{}.{extension}", super::super::random_hex());
|
||||
let dir = session_dir.join("files");
|
||||
if let Err(err) = wg_app_link::private::create_dir(&dir)
|
||||
.map_err(std::io::Error::other)
|
||||
.and_then(|()| std::fs::write(dir.join(&name), bytes))
|
||||
{
|
||||
tracing::error!("couldn't save produced image: {err}");
|
||||
return None;
|
||||
}
|
||||
Some(name)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
Reference in new issue
Block a user