|
| 1 | +use base64::Engine; |
1 | 2 | use serde_json::{json, Value}; |
| 3 | +use std::collections::hash_map::DefaultHasher; |
2 | 4 | use std::collections::{HashMap, HashSet}; |
| 5 | +use std::hash::{Hash, Hasher}; |
3 | 6 | use std::net::IpAddr; |
4 | 7 | use std::path::PathBuf; |
5 | 8 | use std::sync::Arc; |
@@ -576,7 +579,10 @@ pub(crate) async fn resume_thread_core<E: EventSink>( |
576 | 579 | } |
577 | 580 | "file" => { |
578 | 581 | if let Some(url) = part.get("url").and_then(|v| v.as_str()) { |
579 | | - content_parts.push(json!({ "type": "image", "value": url })); |
| 582 | + content_parts.push(json!({ |
| 583 | + "type": "image", |
| 584 | + "value": frontend_image_value(url) |
| 585 | + })); |
580 | 586 | } |
581 | 587 | } |
582 | 588 | _ => {} |
@@ -1102,6 +1108,55 @@ pub(crate) async fn set_thread_name_core( |
1102 | 1108 | const URL_IMAGE_FETCH_TIMEOUT: Duration = Duration::from_secs(10); |
1103 | 1109 | const URL_IMAGE_MAX_BYTES: usize = 8 * 1024 * 1024; |
1104 | 1110 |
|
| 1111 | +fn extension_from_mime(mime: &str) -> &'static str { |
| 1112 | + match mime.trim().to_ascii_lowercase().as_str() { |
| 1113 | + "image/png" => "png", |
| 1114 | + "image/jpeg" => "jpg", |
| 1115 | + "image/gif" => "gif", |
| 1116 | + "image/webp" => "webp", |
| 1117 | + "image/svg+xml" => "svg", |
| 1118 | + "image/bmp" => "bmp", |
| 1119 | + "image/tiff" => "tiff", |
| 1120 | + _ => "bin", |
| 1121 | + } |
| 1122 | +} |
| 1123 | + |
| 1124 | +fn persist_data_image_to_temp_file(data_url: &str) -> Option<String> { |
| 1125 | + let trimmed = data_url.trim(); |
| 1126 | + let (metadata, encoded) = trimmed |
| 1127 | + .strip_prefix("data:")? |
| 1128 | + .split_once(";base64,")?; |
| 1129 | + if !metadata.starts_with("image/") { |
| 1130 | + return None; |
| 1131 | + } |
| 1132 | + let bytes = base64::engine::general_purpose::STANDARD |
| 1133 | + .decode(encoded) |
| 1134 | + .ok()?; |
| 1135 | + |
| 1136 | + let mut hasher = DefaultHasher::new(); |
| 1137 | + metadata.hash(&mut hasher); |
| 1138 | + bytes.hash(&mut hasher); |
| 1139 | + let digest = hasher.finish(); |
| 1140 | + |
| 1141 | + let cache_dir = std::env::temp_dir().join("opencode-monitor-image-cache"); |
| 1142 | + std::fs::create_dir_all(&cache_dir).ok()?; |
| 1143 | + |
| 1144 | + let extension = extension_from_mime(metadata); |
| 1145 | + let path = cache_dir.join(format!("{digest:016x}.{extension}")); |
| 1146 | + if !path.exists() { |
| 1147 | + std::fs::write(&path, &bytes).ok()?; |
| 1148 | + } |
| 1149 | + path.to_str().map(|value| value.to_string()) |
| 1150 | +} |
| 1151 | + |
| 1152 | +fn frontend_image_value(raw: &str) -> String { |
| 1153 | + let trimmed = raw.trim(); |
| 1154 | + if trimmed.starts_with("data:image/") { |
| 1155 | + return persist_data_image_to_temp_file(trimmed).unwrap_or_else(|| trimmed.to_string()); |
| 1156 | + } |
| 1157 | + trimmed.to_string() |
| 1158 | +} |
| 1159 | + |
1105 | 1160 | /// Build REST prompt parts from frontend input. |
1106 | 1161 | /// |
1107 | 1162 | /// REST uses `{ type: "file", mime, url: "data:...", filename }` for images. |
@@ -1141,7 +1196,6 @@ async fn build_rest_prompt_parts( |
1141 | 1196 | // Local file path — read and base64-encode. |
1142 | 1197 | match std::fs::read(trimmed) { |
1143 | 1198 | Ok(bytes) => { |
1144 | | - use base64::Engine; |
1145 | 1199 | let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes); |
1146 | 1200 | let mime = mime_from_extension(trimmed); |
1147 | 1201 | let filename = std::path::Path::new(trimmed) |
@@ -1629,7 +1683,10 @@ pub(crate) async fn send_user_message_core<E: EventSink>( |
1629 | 1683 | if trimmed.is_empty() { |
1630 | 1684 | continue; |
1631 | 1685 | } |
1632 | | - content_parts.push(json!({ "type": "image", "value": trimmed })); |
| 1686 | + content_parts.push(json!({ |
| 1687 | + "type": "image", |
| 1688 | + "value": frontend_image_value(trimmed) |
| 1689 | + })); |
1633 | 1690 | } |
1634 | 1691 | if !content_parts.is_empty() { |
1635 | 1692 | let user_item_id = { |
@@ -2350,6 +2407,18 @@ mod tests { |
2350 | 2407 | }); |
2351 | 2408 | } |
2352 | 2409 |
|
| 2410 | + #[test] |
| 2411 | + fn frontend_image_value_materializes_data_urls_to_temp_files() { |
| 2412 | + let data_url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jXioAAAAASUVORK5CYII="; |
| 2413 | + |
| 2414 | + let path = frontend_image_value(data_url); |
| 2415 | + let repeated = frontend_image_value(data_url); |
| 2416 | + |
| 2417 | + assert!(!path.starts_with("data:")); |
| 2418 | + assert_eq!(path, repeated); |
| 2419 | + assert!(PathBuf::from(&path).exists()); |
| 2420 | + } |
| 2421 | + |
2353 | 2422 | #[test] |
2354 | 2423 | fn hidden_session_ids_are_read_from_workspace_settings() { |
2355 | 2424 | let runtime = Builder::new_current_thread() |
|
0 commit comments