Phase 2 complete: images both ways

Inbound: POST /sessions/{id}/attachments stores a picked photo under the
session; message attachmentIds become base64 image blocks in the
stream-json user message (verified live: an uploaded red PNG answered
"Red."). Outbound: image parts in tool results are decoded into the
session's files/ dir and referenced by Image events -- the transcript
stays lean -- and GET /sessions/{id}/files/{ref} serves them (verified
via the Read tool round-tripping the same PNG). The app grows an attach
button (system photo picker, upload-on-pick) and renders Image events
inline with an authenticated pinned fetch. Sent attachments are echoed
into the transcript as Image events so every device shows them.

Attachments and files are addressed under their session (a deviation
from PLAN.md's original bare /attachments -- recorded there) so their
lifecycle is the session directory's: deleting the session is still the
complete path out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
irisandClaude Fable 5 committed 2026-08-24 21:40:25 -04:00
1 parent 95d389e2b8
commit f2430671a2
7 files changed
+389 -55

No files matched your search

+61 -2
View File
@@ -11,11 +11,13 @@
//! POST /sessions/{id}/interrupt
//! POST /sessions/{id}/model {model}
//! POST /sessions/{id}/compact
//! POST /sessions/{id}/attachments multipart image upload -> {id}, referenced by /message
//! GET /sessions/{id}/files/{name} images the session produced or was sent
//! DELETE /sessions/{id} kill process, delete transcript + files
//! ```
//!
//! Later phases add: `POST /attachments`, `GET /files/{session}/{id}`,
//! `GET /usage`, `GET|PUT /hosts` and `/models` -- see PLAN.md's table.
//! Later phases add: `GET /usage`, `GET|PUT /hosts` and `/models` -- see
//! PLAN.md's table.
//!
//! Everything here works purely in the common event model; nothing may
//! branch on the session kind (that's what drivers are for).
@@ -48,6 +50,10 @@ pub fn router(manager: Arc<SessionManager>) -> Router {
.route("/sessions/{id}/interrupt", post(interrupt))
.route("/sessions/{id}/model", post(set_model))
.route("/sessions/{id}/compact", post(compact))
.route("/sessions/{id}/attachments", post(upload_attachment))
.route("/sessions/{id}/files/{name}", get(serve_file))
// Phone photos overflow axum's 2 MB default body cap.
.layer(axum::extract::DefaultBodyLimit::max(32 * 1024 * 1024))
// An explicit fallback so the auth middleware (layered around the
// whole router in main.rs) also covers unknown paths -- a scanner
// gets the same 401 everywhere, never a route map.
@@ -202,6 +208,59 @@ async fn compact(
Ok(StatusCode::NO_CONTENT)
}
/// Accepts one image (any multipart field) and stores it under the
/// session; the returned id goes into a later `/message`'s attachmentIds.
async fn upload_attachment(
State(manager): State<Arc<SessionManager>>,
UrlPath(id): UrlPath<String>,
mut multipart: axum::extract::Multipart,
) -> Result<axum::Json<serde_json::Value>, ApiError> {
let session = lookup(&manager, &id)?;
let field = multipart
.next_field()
.await
.map_err(|err| ApiError::BadRequest(format!("bad upload: {err}")))?
.ok_or_else(|| ApiError::BadRequest("no file in the upload".to_string()))?;
let content_type = field.content_type().unwrap_or("image/jpeg").to_string();
let bytes = field
.bytes()
.await
.map_err(|err| ApiError::BadRequest(format!("upload read failed: {err}")))?;
let name = session.save_attachment(&bytes, &content_type).map_err(bad_request)?;
Ok(axum::Json(serde_json::json!({ "id": name })))
}
/// Serves a session's stored images -- both `files/` (produced by tools)
/// and `attachments/` (uploaded from the phone), by the id events and
/// uploads reference.
async fn serve_file(
State(manager): State<Arc<SessionManager>>,
UrlPath((id, name)): UrlPath<(String, String)>,
) -> Result<Response, ApiError> {
// Ids are server-generated hex + extension; anything else (and any
// path separator in particular) is refused, not resolved.
if !name.chars().all(|c| c.is_ascii_alphanumeric() || c == '.') || name.contains("..") {
return Err(ApiError::BadRequest("invalid file id".to_string()));
}
let session = lookup(&manager, &id)?;
let candidates =
[session.dir().join("files").join(&name), session.dir().join("attachments").join(&name)];
let Some(path) = candidates.iter().find(|path| path.is_file()) else {
return Err(ApiError::BadRequest(format!("no file {name} in session {id}")));
};
let bytes = std::fs::read(path).map_err(|err| {
tracing::error!("read {} failed: {err}", path.display());
ApiError::BadRequest("file unreadable".to_string())
})?;
let content_type = match name.rsplit('.').next() {
Some("png") => "image/png",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
_ => "image/jpeg",
};
Ok(([(axum::http::header::CONTENT_TYPE, content_type)], bytes).into_response())
}
#[derive(Deserialize)]
struct EventsQuery {
#[serde(default)]