199
}
200
201
let mut parts: Vec<String> = Vec::new();
202
- if let Some(text) = message["content"].as_str()
203
- && !text.trim().is_empty()
204
- {
205
- parts.push(text.to_string());
202
+ let text = message_text(message);
203
+ if !text.trim().is_empty() {
204
+ parts.push(text);
205
+ }
206
+ // A summary cannot carry a picture, but it should say one was there.
207
+ match message_image_count(message) {
208
+ 0 => {}
209
+ 1 => parts.push("[attached an image]".to_string()),
210
+ count => parts.push(format!("[attached {count} images]")),
211
}
212
for call in message["tool_calls"].as_array().into_iter().flatten() {
213
parts.push(format!(
230
/// Deliberately model-agnostic — it only needs to be in the right ballpark to
231
/// decide when compaction is worth an extra inference round.
232
pub fn estimate_tokens(history: &[serde_json::Value]) -> usize {
228
- let chars: usize = history
229
- .iter()
230
- .map(|message| message.to_string().chars().count())
231
- .sum();
232
- chars / 4
233
+ let mut chars = 0;
234
+ let mut images = 0;
235
+ for message in history {
236
+ let image_count = message_image_count(message);
237
+ if image_count == 0 {
238
+ chars += message.to_string().chars().count();
239
+ } else {
240
+ // An image travels as base64, megabytes of it, but a model bills
241
+ // it by resolution. Counting the payload as text would put every
242
+ // session with one screenshot permanently over budget.
243
+ chars += message_text(message).chars().count();
244
+ images += image_count;
245
+ }
246
+ }
247
+ chars / 4 + images * IMAGE_TOKEN_ESTIMATE
248
+}
249
+
250
+/// What one attached image is assumed to cost. Providers land between a few
251
+/// hundred and a couple of thousand tokens depending on resolution; like the
252
+/// rest of [`estimate_tokens`] this only has to be in the right ballpark.
253
+const IMAGE_TOKEN_ESTIMATE: usize = 1_000;
254
+
255
+/// The text of a history message. `content` is a plain string for almost every
256
+/// message; a user message that carries an image uses OpenAI's content-part
257
+/// array instead, and its text parts are joined here.
258
+pub fn message_text(message: &serde_json::Value) -> String {
259
+ match &message["content"] {
260
+ serde_json::Value::String(text) => text.clone(),
261
+ serde_json::Value::Array(parts) => parts
262
+ .iter()
263
+ .filter_map(|part| part["text"].as_str())
264
+ .collect::<Vec<_>>()
265
+ .join("\n"),
266
+ _ => String::new(),
267
+ }
268
+}
269
+
270
+/// How many images a history message carries as `image_url` content parts.
271
+pub fn message_image_count(message: &serde_json::Value) -> usize {
272
+ message["content"]
273
+ .as_array()
274
+ .map(|parts| {
275
+ parts
276
+ .iter()
277
+ .filter(|part| part["type"] == "image_url")
278
+ .count()
279
+ })
280
+ .unwrap_or(0)
281
}
282
283
/// A sink for streaming assistant text deltas to the UI as they are produced.
389
self.engine.clear_history().await;
390
for entry in history {
391
let role = entry["role"].as_str().unwrap_or("");
344
- let content = entry["content"].as_str().unwrap_or("").to_string();
392
+ // The on-device engine keeps text only, so an image attached
393
+ // while a cloud model was active stays behind here.
394
+ let content = message_text(&entry);
395
// Tool-call-only assistant entries and empty tool results carry no
396
// text a plain chat history can replay; drop them.
397
if content.is_empty() && role != "user" && role != "system" {
1745
if let Some(object) = message.as_object_mut() {
1746
object.remove("tool_calls");
1747
}
1698
- if message["content"].as_str().unwrap_or_default().is_empty() {
1748
+ if message_text(&message).is_empty() {
1749
continue;
1750
}
1751
} else {
2102
assert_eq!(history[0]["content"], "new prompt");
2103
}
2104
2105
+ fn user_with_image(text: &str, payload_chars: usize) -> serde_json::Value {
2106
+ serde_json::json!({
2107
+ "role": "user",
2108
+ "content": [
2109
+ { "type": "text", "text": text },
2110
+ { "type": "image_url", "image_url": {
2111
+ "url": format!("data:image/png;base64,{}", "A".repeat(payload_chars)),
2112
+ }},
2113
+ ],
2114
+ })
2115
+ }
2116
+
2117
+ #[test]
2118
+ fn message_text_reads_both_content_shapes() {
2119
+ assert_eq!(
2120
+ message_text(&serde_json::json!({ "role": "user", "content": "hello" })),
2121
+ "hello"
2122
+ );
2123
+ let with_image = user_with_image("what is this?", 16);
2124
+ assert_eq!(message_text(&with_image), "what is this?");
2125
+ assert_eq!(message_image_count(&with_image), 1);
2126
+ // A tool-call-only assistant message has no content at all.
2127
+ let no_content = serde_json::json!({ "role": "assistant", "content": null });
2128
+ assert_eq!(message_text(&no_content), "");
2129
+ assert_eq!(message_image_count(&no_content), 0);
2130
+ }
2131
+
2132
+ #[test]
2133
+ fn estimate_tokens_does_not_count_an_image_payload_as_text() {
2134
+ // Two megabytes of base64 would read as half a million tokens.
2135
+ let history = vec![user_with_image("what is this?", 2_000_000)];
2136
+ let estimate = estimate_tokens(&history);
2137
+ assert!(
2138
+ (IMAGE_TOKEN_ESTIMATE..IMAGE_TOKEN_ESTIMATE + 100).contains(&estimate),
2139
+ "{estimate}"
2140
+ );
2141
+ }
2142
+
2143
+ #[test]
2144
+ fn summary_transcript_keeps_the_text_of_a_message_with_an_image() {
2145
+ let transcript = transcript_for_summary(&[user_with_image("what is this?", 64)]);
2146
+ assert!(transcript.contains("what is this?"), "{transcript}");
2147
+ assert!(transcript.contains("[attached an image]"), "{transcript}");
2148
+ assert!(!transcript.contains("base64"), "{transcript}");
2149
+ }
2150
+
2151
+ #[test]
2152
+ fn carryover_keeps_a_user_message_that_carries_an_image() {
2153
+ let carried = carryover_history(vec![
2154
+ serde_json::json!({ "role": "system", "content": "prompt" }),
2155
+ user_with_image("what is this?", 16),
2156
+ serde_json::json!({ "role": "assistant", "content": "a cat" }),
2157
+ ]);
2158
+ assert_eq!(carried.len(), 2);
2159
+ assert_eq!(message_image_count(&carried[0]), 1);
2160
+ }
2161
+
2162
#[test]
2163
fn estimate_tokens_scales_with_serialized_size() {
2164
assert_eq!(estimate_tokens(&[]), 0);