Attach any file, take shares from other apps, and survive a backwards highlight
Attachments were images only. Now any file can be attached: from the file chooser behind the "+" menu, or from Android's share sheet, which the app is now in. An image still goes to the model as a picture; anything else is stored under its own name (`<hex>-<name>`, cleaned by `safe_file_name`) and the Claude driver ends the message with `Attached file: /abs/path`, since the CLI reads files by path and a model cannot be shown a trace. The user-message field is renamed `images` -> `attachments` on both sides, with a serde alias reading the rows written before. A share arrives before anyone has said which session it is for, so it is held in AppRoot with a banner on the list until a session takes it; an open session takes it at once. Unreadable shares are reported beside the composer, not thrown. The tool card crashed the app when opened on a command holding a quoted glob such as `-path '*/.git/*'`: highlights 1.1.0's shell lexer answers `x '*/a/*'` with a span whose end is before its start, and AnnotatedString refuses the range. Such spans are dropped; the library is the place for the fix. The echo driver gains `/bash <command>` so a card with a given command can be produced on the emulator. ui-sandbox.sh's token salvage read the tokens block's close only at a line start, ran past the compact `),],` the server writes, and copied `setups` into the new config twice, which the server then refused. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
4bc69e8f9c
commit
6180663f14
25 files changed
+672
-165
No files matched your search
+36
-15
@@ -30,8 +30,8 @@
|
||||
//! POST /sessions/{id}/command {text} -- /compact, /clear, /rename x, or the dialect's own
|
||||
//! (starts the process first if it has exited)
|
||||
//! 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
|
||||
//! POST /sessions/{id}/attachments multipart upload, image or any file -> {id}, referenced by /message
|
||||
//! GET /sessions/{id}/files/{name} images the session produced, and what it was sent
|
||||
//! DELETE /sessions/{id} kill process, delete transcript + files
|
||||
//! (?deleteForeign=true removes the machine's own copy too)
|
||||
//! POST /sessions/{id}/notify {notify} -- announce this one or not
|
||||
@@ -110,7 +110,13 @@ pub fn router(manager: Arc<SessionManager>) -> Router {
|
||||
.route("/notifications", get(notifications))
|
||||
.route("/sessions/{id}/compact", post(compact))
|
||||
.route("/sessions/{id}/command", post(command))
|
||||
.route("/sessions/{id}/attachments", post(upload_attachment))
|
||||
.route(
|
||||
"/sessions/{id}/attachments",
|
||||
// A trace or a log is bigger than a photo; the cap below is
|
||||
// for everything else, and the innermost limit is the one
|
||||
// axum applies.
|
||||
post(upload_attachment).layer(axum::extract::DefaultBodyLimit::max(ATTACHMENT_LIMIT)),
|
||||
)
|
||||
.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))
|
||||
@@ -1273,8 +1279,14 @@ async fn compact(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// Accepts one image (any multipart field) and stores it under the
|
||||
/// The most one attachment may be. A day of `perfetto` is under a
|
||||
/// gigabyte; a phone photo is a few megabytes; this is the room between.
|
||||
const ATTACHMENT_LIMIT: usize = 1024 * 1024 * 1024;
|
||||
|
||||
/// Accepts one file (any multipart field) and stores it under the
|
||||
/// session; the returned id goes into a later `/message`'s attachmentIds.
|
||||
/// An image is later shown to the model, anything else is named to it by
|
||||
/// path -- see `ClaudeDriver::send_user_message`.
|
||||
async fn upload_attachment(
|
||||
State(manager): State<Arc<SessionManager>>,
|
||||
UrlPath(id): UrlPath<String>,
|
||||
@@ -1286,27 +1298,36 @@ async fn upload_attachment(
|
||||
.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 content_type = field
|
||||
.content_type()
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
let file_name = field.file_name().map(str::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)
|
||||
.save_attachment(&bytes, &content_type, file_name.as_deref())
|
||||
.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.
|
||||
/// Serves a session's stored files -- both `files/` (images 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("..") {
|
||||
// Ids are server-generated -- hex and an extension, or hex and a
|
||||
// cleaned file name (`safe_file_name`); anything else (and any path
|
||||
// separator in particular) is refused, not resolved.
|
||||
if !name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_')
|
||||
|| name.contains("..")
|
||||
{
|
||||
return Err(ApiError::BadRequest("invalid file id".to_string()));
|
||||
}
|
||||
let session = lookup(&manager, &id)?;
|
||||
@@ -1324,9 +1345,9 @@ async fn serve_file(
|
||||
let bytes = std::fs::read(path)
|
||||
.with_context(|| format!("read {}", path.display()))
|
||||
.map_err(ApiError::Internal)?;
|
||||
// Names are server-generated, so an unrecognized extension can only
|
||||
// mean a file this server didn't write.
|
||||
let content_type = crate::media::media_type_for(&name).unwrap_or("image/jpeg");
|
||||
// Every image this server writes has an extension it knows; the rest
|
||||
// are files attached by name, served as the bytes they are.
|
||||
let content_type = crate::media::media_type_for(&name).unwrap_or("application/octet-stream");
|
||||
Ok(([(axum::http::header::CONTENT_TYPE, content_type)], bytes).into_response())
|
||||
}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ use serde_json::{Value, json};
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use super::driver::{Driver, Event, EventSink, ImageRef, SessionStatus, Unqueued};
|
||||
use super::driver::{AttachmentRef, Driver, Event, EventSink, SessionStatus, Unqueued};
|
||||
use super::process;
|
||||
use super::transport::{Launch, Streams, Transport};
|
||||
use crate::config::{ProviderConfig, SessionConfig};
|
||||
@@ -133,7 +133,7 @@ struct Queue {
|
||||
/// Written, not yet announced, oldest first, each with the id of the
|
||||
/// `MessageQueued` that told the phone it was waiting -- so the
|
||||
/// announcement can name which bubble it resolves.
|
||||
awaiting: VecDeque<(String, String, Vec<ImageRef>)>,
|
||||
awaiting: VecDeque<(String, String, Vec<AttachmentRef>)>,
|
||||
/// The process is gone, so nothing can be taken up any more.
|
||||
///
|
||||
/// Needed because every other way out of a turn is an `Idle` this
|
||||
@@ -550,20 +550,35 @@ impl ClaudeDriver {
|
||||
}
|
||||
|
||||
impl Driver for ClaudeDriver {
|
||||
fn send_user_message(&self, text: String, images: Vec<ImageRef>) {
|
||||
fn send_user_message(&self, text: String, attachments: Vec<AttachmentRef>) {
|
||||
let mut content = Vec::new();
|
||||
for id in &images {
|
||||
match attachment_block(&self.session_dir, id) {
|
||||
Ok(block) => content.push(block),
|
||||
Err(err) => {
|
||||
let _ = self.sink.send(Event::Error {
|
||||
message: format!("attachment {id} couldn't be sent: {err:#}"),
|
||||
});
|
||||
}
|
||||
// An image goes into the message itself; the model looks at it. Any
|
||||
// other file stays where the upload put it and the message says
|
||||
// where, because the CLI can read a file by path and a model cannot
|
||||
// be handed a trace, a log or a zip any other way. Named after the
|
||||
// text, so the words come first, the way they were typed.
|
||||
let mut files = Vec::new();
|
||||
for id in &attachments {
|
||||
let sent = if crate::media::media_type_for(id).is_some() {
|
||||
attachment_block(&self.session_dir, id).map(|block| content.push(block))
|
||||
} else {
|
||||
attachment_path(&self.session_dir, id).map(|path| files.push(path))
|
||||
};
|
||||
if let Err(err) = sent {
|
||||
let _ = self.sink.send(Event::Error {
|
||||
message: format!("attachment {id} couldn't be sent: {err:#}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
if !text.is_empty() {
|
||||
content.push(json!({"type": "text", "text": text}));
|
||||
let mut body = text.clone();
|
||||
for path in files {
|
||||
if !body.is_empty() {
|
||||
body.push_str("\n\n");
|
||||
}
|
||||
body.push_str(&format!("Attached file: {}", path.display()));
|
||||
}
|
||||
if !body.is_empty() {
|
||||
content.push(json!({"type": "text", "text": body}));
|
||||
}
|
||||
let line =
|
||||
json!({"type": "user", "message": {"role": "user", "content": content}}).to_string();
|
||||
@@ -591,9 +606,13 @@ impl Driver for ClaudeDriver {
|
||||
let id = super::random_hex();
|
||||
queue
|
||||
.awaiting
|
||||
.push_back((id.clone(), text.clone(), images.clone()));
|
||||
.push_back((id.clone(), text.clone(), attachments.clone()));
|
||||
drop(queue);
|
||||
let _ = self.sink.send(Event::MessageQueued { id, text, images });
|
||||
let _ = self.sink.send(Event::MessageQueued {
|
||||
id,
|
||||
text,
|
||||
attachments,
|
||||
});
|
||||
self.send_line(line);
|
||||
return;
|
||||
}
|
||||
@@ -605,7 +624,7 @@ impl Driver for ClaudeDriver {
|
||||
let _ = self.sink.send(Event::MessageTaken {
|
||||
id: None,
|
||||
text,
|
||||
images,
|
||||
attachments,
|
||||
});
|
||||
let _ = self.sink.send(Event::Status {
|
||||
state: SessionStatus::Running,
|
||||
@@ -1087,16 +1106,16 @@ fn proves_a_turn(event: &Event) -> bool {
|
||||
/// [`translate_line`], and the pair is the whole of the rule -- a steer
|
||||
/// announced anywhere else lands above output that predates it.
|
||||
fn announce_steers(queue: &Arc<Mutex<Queue>>, sink: &EventSink) -> bool {
|
||||
let taken: Vec<(String, String, Vec<ImageRef>)> = {
|
||||
let taken: Vec<(String, String, Vec<AttachmentRef>)> = {
|
||||
let mut queue = queue.lock().unwrap();
|
||||
queue.awaiting.drain(..).collect()
|
||||
};
|
||||
for (id, text, images) in taken {
|
||||
for (id, text, attachments) in taken {
|
||||
if sink
|
||||
.send(Event::MessageTaken {
|
||||
id: Some(id),
|
||||
text,
|
||||
images,
|
||||
attachments,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
@@ -1185,17 +1204,28 @@ pub(super) fn write_resume_token(session_dir: &Path, session_id: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads an uploaded attachment into an API image content block.
|
||||
fn attachment_block(session_dir: &Path, id: &str) -> Result<Value> {
|
||||
// Ids are server-generated hex (see routes::upload_attachment); the
|
||||
// check keeps a crafted "id" from naming an arbitrary file.
|
||||
/// Where an uploaded attachment is, as a path the CLI can be told.
|
||||
///
|
||||
/// Absolute, because the CLI's working directory is the session's and the
|
||||
/// attachments are not in it. Refused rather than resolved when the id is
|
||||
/// not one this server would have written -- see
|
||||
/// `SessionManager::save_attachment` -- so a crafted id cannot name a file
|
||||
/// outside the session.
|
||||
fn attachment_path(session_dir: &Path, id: &str) -> Result<PathBuf> {
|
||||
if !id
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-')
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_')
|
||||
|| id.contains("..")
|
||||
{
|
||||
anyhow::bail!("invalid attachment id");
|
||||
}
|
||||
let path = session_dir.join("attachments").join(id);
|
||||
std::fs::canonicalize(&path).with_context(|| format!("find {}", path.display()))
|
||||
}
|
||||
|
||||
/// Reads an uploaded image into an API image content block.
|
||||
fn attachment_block(session_dir: &Path, id: &str) -> Result<Value> {
|
||||
let path = attachment_path(session_dir, id)?;
|
||||
let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?;
|
||||
use base64::Engine;
|
||||
Ok(json!({
|
||||
|
||||
@@ -15,6 +15,14 @@ use tokio::sync::mpsc;
|
||||
/// directions use the one id so the transcript renders them identically.
|
||||
pub type ImageRef = String;
|
||||
|
||||
/// The name an upload from the phone is stored and served under: an image
|
||||
/// is `<hex>.<extension>` and is an [`ImageRef`] like any other; any other
|
||||
/// file keeps its own name after the hex, `<hex>-<name>`, because the name
|
||||
/// is what the reader attached and what the session is told. The two are
|
||||
/// told apart by `crate::media::media_type_for`, which knows every image
|
||||
/// extension this server writes.
|
||||
pub type AttachmentRef = String;
|
||||
|
||||
/// One choice offered in answer to a [`Event::Question`].
|
||||
///
|
||||
/// More than a label because the reader is deciding, not confirming: what
|
||||
@@ -89,8 +97,11 @@ pub enum Event {
|
||||
/// sent it -- and left the phone to decide, from nothing but
|
||||
/// adjacency, which message an image belonged to. Belonging is not
|
||||
/// something to infer when the sender knew.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
images: Vec<ImageRef>,
|
||||
///
|
||||
/// `images` on disk until 2026-09-03, when files joined them;
|
||||
/// the alias reads the rows written before that.
|
||||
#[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")]
|
||||
attachments: Vec<AttachmentRef>,
|
||||
},
|
||||
/// A message accepted from the phone that the session cannot read yet.
|
||||
///
|
||||
@@ -111,8 +122,8 @@ pub enum Event {
|
||||
/// Carried for the same reason [`Event::UserMessage`] carries it,
|
||||
/// and it matters more here: a waiting message is on screen for as
|
||||
/// long as the turn runs, so its attachment has nowhere else to be.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
images: Vec<ImageRef>,
|
||||
#[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")]
|
||||
attachments: Vec<AttachmentRef>,
|
||||
},
|
||||
/// A message taken out of the queue before the session read it, by
|
||||
/// somebody tapping the bubble that was waiting for it.
|
||||
@@ -146,8 +157,8 @@ pub enum Event {
|
||||
id: Option<String>,
|
||||
text: String,
|
||||
/// Carried through onto the `UserMessage` with everything else.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
images: Vec<ImageRef>,
|
||||
#[serde(default, alias = "images", skip_serializing_if = "Vec::is_empty")]
|
||||
attachments: Vec<AttachmentRef>,
|
||||
},
|
||||
/// Streaming assistant text; the phone renders the concatenation as
|
||||
/// markdown.
|
||||
@@ -515,7 +526,7 @@ pub trait Driver: Send + Sync {
|
||||
/// moment it actually starts reading it: that event is what puts the
|
||||
/// message in the transcript, so a driver that never sends it drops
|
||||
/// the message from the conversation entirely.
|
||||
fn send_user_message(&self, text: String, images: Vec<ImageRef>);
|
||||
fn send_user_message(&self, text: String, attachments: Vec<AttachmentRef>);
|
||||
/// Takes back a message that is still waiting, named by the id its
|
||||
/// [`Event::MessageQueued`] carried.
|
||||
///
|
||||
|
||||
+44
-16
@@ -7,6 +7,8 @@
|
||||
//! A leading word asks for something more specific:
|
||||
//!
|
||||
//! - `/tool [input]` -- a full tool run, start through end.
|
||||
//! - `/bash [command]` -- a Bash call carrying that command, for what the
|
||||
//! phone's shell highlighting does to a particular line.
|
||||
//! - `/tools [n] [gap]` -- n calls back to back, for what a run of them
|
||||
//! looks like when a screen groups them. `gap` is seconds between one
|
||||
//! call and the next, default none: it is what makes a run *grow* while
|
||||
@@ -37,7 +39,7 @@
|
||||
//! shape a real model's reply arrives in, and the one where the row a
|
||||
//! reader is anchored to is the row that keeps changing height.
|
||||
//! - `/mixed N` -- N beats of an interleaved transcript: paragraphs of
|
||||
//! different lengths, single tool calls, runs of adjacent ones, images
|
||||
//! different lengths, single tool calls, runs of adjacent ones, attachments
|
||||
//! and a peer message. Rows of every shape and height the app draws, in
|
||||
//! one session, which is what a scrolling problem needs in order to be
|
||||
//! reproduced twice the same way.
|
||||
@@ -55,7 +57,9 @@ use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use super::driver::{Driver, Event, EventSink, ImageRef, QuestionOption, SessionStatus, Unqueued};
|
||||
use super::driver::{
|
||||
AttachmentRef, Driver, Event, EventSink, QuestionOption, SessionStatus, Unqueued,
|
||||
};
|
||||
|
||||
/// Delay between streamed deltas -- long enough that streaming is visibly
|
||||
/// streaming in the UI, short enough that tests waiting on a full turn
|
||||
@@ -94,7 +98,7 @@ pub struct EchoDriver {
|
||||
/// Held messages with the id of the `MessageQueued` each one announced,
|
||||
/// so the announcement can say which waiting bubble it resolves.
|
||||
queued: Arc<Mutex<Vec<Held>>>,
|
||||
/// Where `/mixed` writes the images it references, which is the same
|
||||
/// Where `/mixed` writes the attachments it references, which is the same
|
||||
/// directory the files route serves them from.
|
||||
session_dir: PathBuf,
|
||||
/// Ids of the questions awaiting an answer, in the order they were
|
||||
@@ -249,7 +253,7 @@ impl EchoDriver {
|
||||
/// transcript, and a command is not -- the manager has already
|
||||
/// recorded that one was sent, and saying so twice drew the same
|
||||
/// line in both colours.
|
||||
fn handle(&self, text: String, images: Vec<ImageRef>, announce: bool) {
|
||||
fn handle(&self, text: String, attachments: Vec<AttachmentRef>, announce: bool) {
|
||||
let sink = self.sink.clone();
|
||||
|
||||
// Mid-turn messages are held rather than answered, the way a real
|
||||
@@ -265,9 +269,13 @@ impl EchoDriver {
|
||||
self.queued
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((id.clone(), text.clone(), images.clone()));
|
||||
.push((id.clone(), text.clone(), attachments.clone()));
|
||||
if announce {
|
||||
self.emit(Event::MessageQueued { id, text, images });
|
||||
self.emit(Event::MessageQueued {
|
||||
id,
|
||||
text,
|
||||
attachments,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -288,7 +296,7 @@ impl EchoDriver {
|
||||
self.emit(Event::MessageTaken {
|
||||
id: None,
|
||||
text: text.clone(),
|
||||
images: images.clone(),
|
||||
attachments: attachments.clone(),
|
||||
});
|
||||
}
|
||||
self.emit(Event::Status {
|
||||
@@ -319,7 +327,7 @@ impl EchoDriver {
|
||||
self.emit(Event::MessageTaken {
|
||||
id: None,
|
||||
text: text.clone(),
|
||||
images: images.clone(),
|
||||
attachments: attachments.clone(),
|
||||
});
|
||||
}
|
||||
self.emit(Event::PeerMessage {
|
||||
@@ -345,7 +353,7 @@ impl EchoDriver {
|
||||
self.emit(Event::MessageTaken {
|
||||
id: None,
|
||||
text,
|
||||
images,
|
||||
attachments,
|
||||
});
|
||||
}
|
||||
self.compact();
|
||||
@@ -357,7 +365,7 @@ impl EchoDriver {
|
||||
self.emit(Event::MessageTaken {
|
||||
id: None,
|
||||
text,
|
||||
images,
|
||||
attachments,
|
||||
});
|
||||
}
|
||||
self.ask_user_question();
|
||||
@@ -427,6 +435,9 @@ impl EchoDriver {
|
||||
text.strip_prefix("/tool")
|
||||
.map(|rest| rest.trim().to_string())
|
||||
};
|
||||
let run_bash = text
|
||||
.strip_prefix("/bash")
|
||||
.map(|rest| rest.trim().to_string());
|
||||
// Seconds to stay running before answering, default 30. Clamped
|
||||
// rather than trusted: this is a test affordance, and a session
|
||||
// pinned running for an hour by a typo is a worse outcome than a
|
||||
@@ -469,7 +480,7 @@ impl EchoDriver {
|
||||
send(Event::MessageTaken {
|
||||
id: None,
|
||||
text: text.clone(),
|
||||
images: images.clone(),
|
||||
attachments: attachments.clone(),
|
||||
});
|
||||
}
|
||||
send(Event::Status {
|
||||
@@ -585,6 +596,23 @@ impl EchoDriver {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(command) = run_bash {
|
||||
let id = format!("b-{}", super::random_hex());
|
||||
send(Event::ToolStart {
|
||||
id: id.clone(),
|
||||
tool: "Bash".to_string(),
|
||||
input: serde_json::json!({
|
||||
"command": command,
|
||||
"description": "Run what /bash was given",
|
||||
}),
|
||||
});
|
||||
tokio::time::sleep(DELTA_DELAY).await;
|
||||
send(Event::ToolEnd {
|
||||
id,
|
||||
output: format!("ran: {command}"),
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(input) = run_tool {
|
||||
let id = format!("t-{}", super::random_hex());
|
||||
send(Event::ToolStart {
|
||||
@@ -762,7 +790,7 @@ async fn write_beat(sink: &EventSink, session_dir: &Path, beat: usize) {
|
||||
/// it. All three, because all three are what the `MessageTaken` at the other
|
||||
/// end owes -- named rather than written out at each of the four places that
|
||||
/// mention it.
|
||||
type Held = (String, String, Vec<ImageRef>);
|
||||
type Held = (String, String, Vec<AttachmentRef>);
|
||||
|
||||
/// A markdown table [columns] wide, with cells too long for one line.
|
||||
///
|
||||
@@ -832,7 +860,7 @@ fn markdown_table(columns: usize) -> String {
|
||||
/// of them owes the same answer.
|
||||
fn finish_turn(sink: &EventSink, queued: &Mutex<Vec<Held>>, busy: &AtomicBool) {
|
||||
let held = std::mem::take(&mut *queued.lock().unwrap());
|
||||
for (id, text, images) in held {
|
||||
for (id, text, attachments) in held {
|
||||
// Announced before it is answered, in that order: a phone showing
|
||||
// the message as pending needs the signal that it has been read,
|
||||
// and the answer is meaningless above a message still drawn as
|
||||
@@ -840,7 +868,7 @@ fn finish_turn(sink: &EventSink, queued: &Mutex<Vec<Held>>, busy: &AtomicBool) {
|
||||
let _ = sink.send(Event::MessageTaken {
|
||||
id: Some(id),
|
||||
text: text.clone(),
|
||||
images,
|
||||
attachments,
|
||||
});
|
||||
let _ = sink.send(Event::AssistantText {
|
||||
delta: format!("\n(taken from the queue) You said: {text}"),
|
||||
@@ -874,14 +902,14 @@ impl Driver for EchoDriver {
|
||||
Unqueued::Dropped
|
||||
}
|
||||
|
||||
fn send_user_message(&self, text: String, images: Vec<ImageRef>) {
|
||||
fn send_user_message(&self, text: String, attachments: Vec<AttachmentRef>) {
|
||||
// Announced, because this is a message: every driver owes exactly
|
||||
// one `MessageTaken` per message, and one that quietly vanishes
|
||||
// from the transcript is the thing echo must not model. A command
|
||||
// owes none -- the manager has already recorded that it was sent,
|
||||
// and announcing it again drew the same line twice, once in each
|
||||
// colour.
|
||||
self.handle(text, images, true);
|
||||
self.handle(text, attachments, true);
|
||||
}
|
||||
|
||||
/// Echo's commands *are* its messages -- `/tool`, `/slow`, `/ask` --
|
||||
|
||||
@@ -628,7 +628,7 @@ fn push_user(events: &mut Vec<Event>, content: &Value, session_dir: &std::path::
|
||||
events.push(Event::UserMessage {
|
||||
id: None,
|
||||
text,
|
||||
images: Vec::new(),
|
||||
attachments: Vec::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+16
-16
@@ -33,7 +33,7 @@ use anyhow::{Context, Result, bail};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
|
||||
use super::driver::{Driver, Event, EventSink, ImageRef, SessionStatus};
|
||||
use super::driver::{AttachmentRef, Driver, Event, EventSink, SessionStatus};
|
||||
use super::process;
|
||||
use super::transport::{Launch, Streams, Transport};
|
||||
use crate::config::{ProviderConfig, SessionConfig};
|
||||
@@ -322,10 +322,10 @@ fn watch(session_dir: PathBuf, sink: EventSink) {
|
||||
}
|
||||
|
||||
impl Driver for LlamaDriver {
|
||||
fn send_user_message(&self, text: String, images: Vec<ImageRef>) {
|
||||
if !images.is_empty() {
|
||||
fn send_user_message(&self, text: String, attachments: Vec<AttachmentRef>) {
|
||||
if !attachments.is_empty() {
|
||||
let _ = self.sink.send(Event::Error {
|
||||
message: "this model can't be sent images".to_string(),
|
||||
message: "this model can't be sent attachments or files".to_string(),
|
||||
});
|
||||
}
|
||||
let sink = self.sink.clone();
|
||||
@@ -344,9 +344,9 @@ impl Driver for LlamaDriver {
|
||||
let _ = sink.send(Event::MessageTaken {
|
||||
id: None,
|
||||
text: text.clone(),
|
||||
// Never any: this driver refuses images above, and saying
|
||||
// so is what the refusal above is for.
|
||||
images: Vec::new(),
|
||||
// Never any: this driver refuses attachments above, and
|
||||
// saying so is what the refusal above is for.
|
||||
attachments: Vec::new(),
|
||||
});
|
||||
let _ = sink.send(Event::Status {
|
||||
state: SessionStatus::Running,
|
||||
@@ -649,7 +649,7 @@ mod tests {
|
||||
Event::UserMessage {
|
||||
id: None,
|
||||
text: "hello".into(),
|
||||
images: Vec::new(),
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
Event::AssistantText {
|
||||
delta: "hi ".into(),
|
||||
@@ -663,7 +663,7 @@ mod tests {
|
||||
Event::UserMessage {
|
||||
id: None,
|
||||
text: "again".into(),
|
||||
images: Vec::new(),
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
Event::AssistantText {
|
||||
delta: "yes".into(),
|
||||
@@ -695,7 +695,7 @@ mod tests {
|
||||
Event::UserMessage {
|
||||
id: None,
|
||||
text: "count".into(),
|
||||
images: Vec::new(),
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
Event::AssistantText {
|
||||
delta: "one two".into(),
|
||||
@@ -721,7 +721,7 @@ mod tests {
|
||||
Event::UserMessage {
|
||||
id: None,
|
||||
text: "hello".into(),
|
||||
images: Vec::new(),
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
Event::Error {
|
||||
message: "something went wrong".into(),
|
||||
@@ -749,7 +749,7 @@ mod tests {
|
||||
Event::UserMessage {
|
||||
id: None,
|
||||
text: "the long expensive conversation".into(),
|
||||
images: Vec::new(),
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
Event::AssistantText {
|
||||
delta: "at length".into(),
|
||||
@@ -758,7 +758,7 @@ mod tests {
|
||||
Event::UserMessage {
|
||||
id: None,
|
||||
text: "a fresh start".into(),
|
||||
images: Vec::new(),
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
Event::AssistantText {
|
||||
delta: "cheaply".into(),
|
||||
@@ -778,19 +778,19 @@ mod tests {
|
||||
Event::UserMessage {
|
||||
id: None,
|
||||
text: "one".into(),
|
||||
images: Vec::new(),
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
Event::Cleared,
|
||||
Event::UserMessage {
|
||||
id: None,
|
||||
text: "two".into(),
|
||||
images: Vec::new(),
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
Event::Cleared,
|
||||
Event::UserMessage {
|
||||
id: None,
|
||||
text: "three".into(),
|
||||
images: Vec::new(),
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
]);
|
||||
let messages = conversation(&path);
|
||||
|
||||
+65
-11
@@ -33,7 +33,7 @@ use crate::config::{
|
||||
};
|
||||
use claude::ClaudeDriver;
|
||||
use driver::{
|
||||
Driver, Event, EventSink, ImageRef, SessionCommand, SessionStatus, Unqueued, context_after,
|
||||
AttachmentRef, Driver, Event, EventSink, SessionCommand, SessionStatus, Unqueued, context_after,
|
||||
};
|
||||
use echo::EchoDriver;
|
||||
use llama::LlamaDriver;
|
||||
@@ -417,14 +417,14 @@ impl LiveSession {
|
||||
/// The message is deliberately not recorded here. Sent into a running
|
||||
/// turn it waits, and writing it down on the way past would put it
|
||||
/// above output that happened before the session ever saw it.
|
||||
pub fn send_message(&self, text: String, images: Vec<ImageRef>) {
|
||||
pub fn send_message(&self, text: String, attachments: Vec<AttachmentRef>) {
|
||||
// The attachments ride *on* the message rather than as `Image`
|
||||
// events emitted just before it. They used to be the latter, which
|
||||
// drew a person's screenshot as a row floating above the bubble
|
||||
// that sent it, and left the phone inferring from adjacency which
|
||||
// message an image went with -- a thing the sender already knew.
|
||||
self.ask("take a message", |driver| {
|
||||
driver.send_user_message(text, images)
|
||||
driver.send_user_message(text, attachments)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -488,11 +488,24 @@ impl LiveSession {
|
||||
/// Stores one uploaded attachment, returning the id `POST /message`
|
||||
/// references it by. Removed with the session directory on delete --
|
||||
/// the same path out as everything else in it.
|
||||
pub fn save_attachment(&self, bytes: &[u8], content_type: &str) -> Result<String> {
|
||||
// An unrecognized type is almost always a phone photo whose
|
||||
// content type the picker didn't set; jpg is the useful guess.
|
||||
let extension = crate::media::extension_for(content_type).unwrap_or("jpg");
|
||||
let name = format!("{}.{extension}", random_hex());
|
||||
///
|
||||
/// An image is named `<hex>.<extension>` and nothing else, since the
|
||||
/// model is shown the picture rather than told its name. Anything else
|
||||
/// keeps the name it arrived with after the hex: the session is told
|
||||
/// the path, and a trace called `trace-komodo-….perfetto-trace` says
|
||||
/// more to it than `3f9a…` would. The name is cleaned to characters a
|
||||
/// path and a URL both take unquoted, and the hex keeps two uploads of
|
||||
/// the same name apart. `AttachmentRef` documents the two shapes.
|
||||
pub fn save_attachment(
|
||||
&self,
|
||||
bytes: &[u8],
|
||||
content_type: &str,
|
||||
file_name: Option<&str>,
|
||||
) -> Result<AttachmentRef> {
|
||||
let name = match crate::media::extension_for(content_type) {
|
||||
Some(extension) => format!("{}.{extension}", random_hex()),
|
||||
None => format!("{}-{}", random_hex(), safe_file_name(file_name)),
|
||||
};
|
||||
let dir = self.dir().join("attachments");
|
||||
wg_app_link::private::create_dir(&dir)?;
|
||||
std::fs::write(dir.join(&name), bytes)
|
||||
@@ -1442,14 +1455,19 @@ impl SessionManager {
|
||||
/// Started before the message rather than after, because starting
|
||||
/// replaces the driver and the driver that takes the message has to be
|
||||
/// the one with a process behind it.
|
||||
pub fn send_message(&self, id: &str, text: String, images: Vec<ImageRef>) -> Result<()> {
|
||||
pub fn send_message(
|
||||
&self,
|
||||
id: &str,
|
||||
text: String,
|
||||
attachments: Vec<AttachmentRef>,
|
||||
) -> Result<()> {
|
||||
// Only `Exited` starts anything -- see `start_if_exited`. A session
|
||||
// this cannot say has exited keeps the behaviour it always had: the
|
||||
// message goes to the driver, which answers for it.
|
||||
self.start_if_exited(id)?;
|
||||
self.session(id)
|
||||
.with_context(|| format!("no session {id}"))?
|
||||
.send_message(text, images);
|
||||
.send_message(text, attachments);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1767,6 +1785,34 @@ fn names<'a>(all: impl Iterator<Item = &'a str>) -> String {
|
||||
|
||||
/// 8 random bytes, hex -- short enough for a URL, unique enough forever at
|
||||
/// this scale.
|
||||
/// A file name reduced to what an attachment id may hold: letters, digits,
|
||||
/// `.`, `-` and `_`, no run of dots that could read as a parent directory,
|
||||
/// at most [`FILE_NAME_LIMIT`] characters keeping the tail (the extension
|
||||
/// is what identifies a file), and `file` when nothing usable is left.
|
||||
fn safe_file_name(name: Option<&str>) -> String {
|
||||
let cleaned: String = name
|
||||
.unwrap_or_default()
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let cleaned = cleaned.replace("..", "_").trim_matches('.').to_string();
|
||||
if cleaned.is_empty() {
|
||||
return "file".to_string();
|
||||
}
|
||||
let excess = cleaned.chars().count().saturating_sub(FILE_NAME_LIMIT);
|
||||
cleaned.chars().skip(excess).collect()
|
||||
}
|
||||
|
||||
/// Longer than any name a person types, shorter than what a filesystem
|
||||
/// refuses once the hex and a dash are in front of it.
|
||||
const FILE_NAME_LIMIT: usize = 120;
|
||||
|
||||
pub fn random_hex() -> String {
|
||||
use rand::Rng;
|
||||
let mut bytes = [0u8; 8];
|
||||
@@ -2232,7 +2278,15 @@ async fn pump(
|
||||
// the position is the only way a reader can put it back where it
|
||||
// happened -- see `Event::PeerMessage::turn_start`.
|
||||
let event = match event {
|
||||
Event::MessageTaken { id, text, images } => Event::UserMessage { id, text, images },
|
||||
Event::MessageTaken {
|
||||
id,
|
||||
text,
|
||||
attachments,
|
||||
} => Event::UserMessage {
|
||||
id,
|
||||
text,
|
||||
attachments,
|
||||
},
|
||||
Event::PeerMessage { from, text, .. } => Event::PeerMessage {
|
||||
from,
|
||||
text,
|
||||
|
||||
@@ -636,7 +636,7 @@ mod tests {
|
||||
Event::UserMessage {
|
||||
id: None,
|
||||
text: "hi".into(),
|
||||
images: Vec::new(),
|
||||
attachments: Vec::new(),
|
||||
},
|
||||
text("hello"),
|
||||
Event::ToolStart {
|
||||
|
||||
Reference in new issue
Block a user