Compare commits

...

3 Commits

Author SHA1 Message Date
Shaun Arman
a748df824b chore: update Cargo.lock for multipart feature 2026-04-09 17:15:02 -05:00
Shaun Arman
31df73c6d0 feat: add use_datastore_upload config option for GenAI
- Add use_datastore_upload boolean to ProviderConfig (Rust and TypeScript)
- Add config field to AI provider form UI toggle
- Update upload_file_to_datastore to use correct auth headers
- Fix LogUpload file upload to use file dialog for paths
- Support LogFile to ImageAttachment conversion
- Update Cargo.toml for multipart support in reqwest
2026-04-09 17:14:41 -05:00
Shaun Arman
8a88f046d3 feat(image): add BMP support and upload_file_to_datastore command
- Add image/bmp to supported MIME types
- New upload_file_to_datastore command for GenAI datastore file uploads
  - Uses multipart/form-data with session ID authentication
  - Returns ImageAttachment for consistency
- Updated test to expect BMP support (was negative test)
2026-04-09 17:13:58 -05:00
9 changed files with 252 additions and 52 deletions

1
src-tauri/Cargo.lock generated
View File

@ -4242,6 +4242,7 @@ dependencies = [
"js-sys", "js-sys",
"log", "log",
"mime", "mime",
"mime_guess",
"native-tls", "native-tls",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",

View File

@ -21,7 +21,7 @@ rusqlite = { version = "0.31", features = ["bundled-sqlcipher-vendored-openssl"]
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json", "stream"] } reqwest = { version = "0.12", features = ["json", "stream", "multipart"] }
regex = "1" regex = "1"
aho-corasick = "1" aho-corasick = "1"
uuid = { version = "1", features = ["v7"] } uuid = { version = "1", features = ["v7"] }

View File

@ -8,12 +8,13 @@ use crate::db::models::{AuditEntry, ImageAttachment};
use crate::state::AppState; use crate::state::AppState;
const MAX_IMAGE_FILE_BYTES: u64 = 10 * 1024 * 1024; const MAX_IMAGE_FILE_BYTES: u64 = 10 * 1024 * 1024;
const SUPPORTED_IMAGE_MIME_TYPES: [&str; 5] = [ const SUPPORTED_IMAGE_MIME_TYPES: [&str; 6] = [
"image/png", "image/png",
"image/jpeg", "image/jpeg",
"image/gif", "image/gif",
"image/webp", "image/webp",
"image/svg+xml", "image/svg+xml",
"image/bmp",
]; ];
fn validate_image_file_path(file_path: &str) -> Result<std::path::PathBuf, String> { fn validate_image_file_path(file_path: &str) -> Result<std::path::PathBuf, String> {
@ -265,6 +266,128 @@ pub async fn delete_image_attachment(
Ok(()) Ok(())
} }
#[tauri::command]
pub async fn upload_file_to_datastore(
issue_id: String,
file_path: String,
session_id: String,
api_url: String,
api_key: String,
state: State<'_, AppState>,
) -> Result<ImageAttachment, String> {
use reqwest::Client;
let canonical_path = std::path::Path::new(&file_path);
let canonical = std::fs::canonicalize(canonical_path)
.map_err(|_| "Unable to access file")?;
let file_name = canonical
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
let content = std::fs::read(&canonical).map_err(|_| "Failed to read file")?;
let file_size = content.len() as i64;
// Upload hash before consuming content
let _upload_hash = format!("{:x}", sha2::Sha256::digest(&content));
// Clone for upload (reqwest multipart consumes content)
let content_clone = content.clone();
// Build the upload URL
let api_url = api_url.trim_end_matches('/');
let upload_url = format!("{}/upload/{}", api_url, session_id);
// Upload file using multipart/form-data
let client = Client::builder()
.timeout(std::time::Duration::from_secs(60))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
let resp = client
.post(&upload_url)
.header("x-msi-genai-api-key", api_key)
.multipart(reqwest::multipart::Form::new().part("file", reqwest::multipart::Part::bytes(content_clone).file_name(file_name.clone())))
.send()
.await
.map_err(|e| format!("Failed to upload file: {}", e))?;
if !resp.status().is_success() {
let status = resp.status();
let text = resp.text().await.unwrap_or_else(|_| "unable to read response".to_string());
return Err(format!("File upload failed: {} - {}", status, text));
}
let response_json: serde_json::Value = resp.json().await
.map_err(|e| format!("Failed to parse upload response: {}", e))?;
if !response_json["status"].as_bool().unwrap_or(false) {
let msg = response_json["msg"].as_str().unwrap_or("Unknown error");
return Err(format!("Upload failed: {}", msg));
}
// Create ImageAttachment record
let upload_hash = format!("{:x}", sha2::Sha256::digest(&content));
let mime_type = infer::get(&content)
.map(|m| m.mime_type().to_string())
.unwrap_or_else(|| "application/octet-stream".to_string());
let attachment = ImageAttachment::new(
issue_id.clone(),
file_name,
canonical.to_string_lossy().to_string(),
file_size,
mime_type,
upload_hash,
true,
false,
);
let db = state.db.lock().map_err(|e| e.to_string())?;
db.execute(
"INSERT INTO image_attachments (id, issue_id, file_name, file_path, file_size, mime_type, upload_hash, uploaded_at, pii_warning_acknowledged, is_paste) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
rusqlite::params![
attachment.id,
attachment.issue_id,
attachment.file_name,
attachment.file_path,
attachment.file_size,
attachment.mime_type,
attachment.upload_hash,
attachment.uploaded_at,
attachment.pii_warning_acknowledged as i32,
attachment.is_paste as i32,
],
)
.map_err(|_| "Failed to store file metadata".to_string())?;
let entry = AuditEntry::new(
"upload_file_to_datastore".to_string(),
"image_attachment".to_string(),
attachment.id.clone(),
serde_json::json!({
"issue_id": issue_id,
"file_name": attachment.file_name,
"session_id": session_id,
})
.to_string(),
);
if let Err(err) = write_audit_event(
&db,
&entry.action,
&entry.entity_type,
&entry.entity_id,
&entry.details,
) {
tracing::warn!(error = %err, "failed to write upload_file_to_datastore audit entry");
}
Ok(attachment)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -276,7 +399,13 @@ mod tests {
assert!(is_supported_image_format("image/gif")); assert!(is_supported_image_format("image/gif"));
assert!(is_supported_image_format("image/webp")); assert!(is_supported_image_format("image/webp"));
assert!(is_supported_image_format("image/svg+xml")); assert!(is_supported_image_format("image/svg+xml"));
assert!(!is_supported_image_format("image/bmp")); assert!(is_supported_image_format("image/bmp"));
assert!(!is_supported_image_format("text/plain")); assert!(!is_supported_image_format("text/plain"));
} }
#[test]
fn test_add_bmp_support() {
// BMP support added
assert!(is_supported_image_format("image/bmp"));
}
} }

View File

@ -158,8 +158,8 @@ pub async fn save_ai_provider(
db.execute( db.execute(
"INSERT OR REPLACE INTO ai_providers "INSERT OR REPLACE INTO ai_providers
(id, name, provider_type, api_url, encrypted_api_key, model, max_tokens, temperature, (id, name, provider_type, api_url, encrypted_api_key, model, max_tokens, temperature,
custom_endpoint_path, custom_auth_header, custom_auth_prefix, api_format, user_id, updated_at) custom_endpoint_path, custom_auth_header, custom_auth_prefix, api_format, user_id, use_datastore_upload, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, datetime('now'))", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, datetime('now'))",
rusqlite::params![ rusqlite::params![
uuid::Uuid::now_v7().to_string(), uuid::Uuid::now_v7().to_string(),
provider.name, provider.name,
@ -174,6 +174,7 @@ pub async fn save_ai_provider(
provider.custom_auth_prefix, provider.custom_auth_prefix,
provider.api_format, provider.api_format,
provider.user_id, provider.user_id,
provider.use_datastore_upload,
], ],
) )
.map_err(|e| format!("Failed to save AI provider: {e}"))?; .map_err(|e| format!("Failed to save AI provider: {e}"))?;
@ -190,10 +191,11 @@ pub async fn load_ai_providers(
let mut stmt = db let mut stmt = db
.prepare( .prepare(
"SELECT name, provider_type, api_url, encrypted_api_key, model, max_tokens, temperature, "SELECT name, provider_type, api_url, encrypted_api_key, model, max_tokens, temperature,
custom_endpoint_path, custom_auth_header, custom_auth_prefix, api_format, user_id custom_endpoint_path, custom_auth_header, custom_auth_prefix, api_format, user_id,
FROM ai_providers use_datastore_upload
ORDER BY name", FROM ai_providers
ORDER BY name",
) )
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@ -212,29 +214,31 @@ pub async fn load_ai_providers(
row.get::<_, Option<String>>(7)?, // custom_endpoint_path row.get::<_, Option<String>>(7)?, // custom_endpoint_path
row.get::<_, Option<String>>(8)?, // custom_auth_header row.get::<_, Option<String>>(8)?, // custom_auth_header
row.get::<_, Option<String>>(9)?, // custom_auth_prefix row.get::<_, Option<String>>(9)?, // custom_auth_prefix
row.get::<_, Option<String>>(10)?, // api_format row.get::<_, Option<String>>(10)?, // api_format
row.get::<_, Option<String>>(11)?, // user_id row.get::<_, Option<String>>(11)?, // user_id
)) row.get::<_, Option<bool>>(12)?, // use_datastore_upload
}) ))
.map_err(|e| e.to_string())? })
.filter_map(|r| r.ok()) .map_err(|e| e.to_string())?
.filter_map( .filter_map(|r| r.ok())
|( .filter_map(
name, |(
provider_type, name,
api_url, provider_type,
encrypted_key, api_url,
model, encrypted_key,
max_tokens, model,
temperature, max_tokens,
custom_endpoint_path, temperature,
custom_auth_header, custom_endpoint_path,
custom_auth_prefix, custom_auth_header,
api_format, custom_auth_prefix,
user_id, api_format,
)| { user_id,
// Decrypt the API key _use_datastore_upload,
let api_key = crate::integrations::auth::decrypt_token(&encrypted_key).ok()?; )| {
// Decrypt the API key
let api_key = crate::integrations::auth::decrypt_token(&encrypted_key).ok()?;
Some(ProviderConfig { Some(ProviderConfig {
name, name,
@ -250,6 +254,7 @@ pub async fn load_ai_providers(
api_format, api_format,
session_id: None, // Session IDs are not persisted session_id: None, // Session IDs are not persisted
user_id, user_id,
use_datastore_upload: None,
}) })
}, },
) )

View File

@ -73,11 +73,12 @@ pub fn run() {
commands::analysis::upload_log_file, commands::analysis::upload_log_file,
commands::analysis::detect_pii, commands::analysis::detect_pii,
commands::analysis::apply_redactions, commands::analysis::apply_redactions,
commands::image::upload_image_attachment, commands::image::upload_image_attachment,
commands::image::list_image_attachments, commands::image::list_image_attachments,
commands::image::delete_image_attachment, commands::image::delete_image_attachment,
commands::image::upload_paste_image, commands::image::upload_paste_image,
// AI commands::image::upload_file_to_datastore,
// AI
commands::ai::analyze_logs, commands::ai::analyze_logs,
commands::ai::chat_message, commands::ai::chat_message,
commands::ai::test_provider_connection, commands::ai::test_provider_connection,

View File

@ -39,6 +39,9 @@ pub struct ProviderConfig {
/// Optional: User ID for custom REST API cost tracking (CORE ID email) /// Optional: User ID for custom REST API cost tracking (CORE ID email)
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub user_id: Option<String>, pub user_id: Option<String>,
/// Optional: Enable file upload to datastore (for providers that support it)
#[serde(skip_serializing_if = "Option::is_none")]
pub use_datastore_upload: Option<bool>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]

View File

@ -16,6 +16,7 @@ export interface ProviderConfig {
api_format?: string; api_format?: string;
session_id?: string; session_id?: string;
user_id?: string; user_id?: string;
use_datastore_upload?: boolean;
} }
export interface Message { export interface Message {
@ -283,6 +284,9 @@ export const uploadImageAttachmentCmd = (issueId: string, filePath: string) =>
export const uploadPasteImageCmd = (issueId: string, base64Image: string, mimeType: string) => export const uploadPasteImageCmd = (issueId: string, base64Image: string, mimeType: string) =>
invoke<ImageAttachment>("upload_paste_image", { issueId, base64Image, mimeType }); invoke<ImageAttachment>("upload_paste_image", { issueId, base64Image, mimeType });
export const uploadFileToDatastoreCmd = (issueId: string, filePath: string, sessionId: string, api_url: string, api_key: string) =>
invoke<ImageAttachment>("upload_file_to_datastore", { issueId, filePath, session_id: sessionId, api_url, api_key });
export const listImageAttachmentsCmd = (issueId: string) => export const listImageAttachmentsCmd = (issueId: string) =>
invoke<ImageAttachment[]>("list_image_attachments", { issueId }); invoke<ImageAttachment[]>("list_image_attachments", { issueId });

View File

@ -9,6 +9,7 @@ import {
detectPiiCmd, detectPiiCmd,
uploadImageAttachmentCmd, uploadImageAttachmentCmd,
uploadPasteImageCmd, uploadPasteImageCmd,
uploadFileToDatastoreCmd,
listImageAttachmentsCmd, listImageAttachmentsCmd,
deleteImageAttachmentCmd, deleteImageAttachmentCmd,
type LogFile, type LogFile,
@ -17,6 +18,7 @@ import {
type ImageAttachment, type ImageAttachment,
} from "@/lib/tauriCommands"; } from "@/lib/tauriCommands";
import ImageGallery from "@/components/ImageGallery"; import ImageGallery from "@/components/ImageGallery";
import { open } from "@tauri-apps/plugin-dialog";
export default function LogUpload() { export default function LogUpload() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
@ -106,23 +108,43 @@ export default function LogUpload() {
}; };
const handleImageDrop = useCallback( const handleImageDrop = useCallback(
(e: React.DragEvent) => { async (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
const droppedFiles = Array.from(e.dataTransfer.files); // Use file dialog to get actual paths (drag-and-drop doesn't give paths in Tauri v2)
const imageFiles = droppedFiles.filter((f) => f.type.startsWith("image/")); try {
const selected = await open({
multiple: true,
filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg", "gif", "bmp", "webp"] }],
});
if (!selected) return;
const paths = Array.isArray(selected) ? selected : [selected];
if (imageFiles.length > 0) { if (paths.length > 0) {
handleImagesUpload(imageFiles); handleImagesUpload(paths as unknown as File[]);
}
} catch (err) {
setError(`Attachment failed: ${String(err)}`);
} }
}, },
[id] [id]
); );
const handleImageFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { const handleImageFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) { if (e.target.files && e.target.files.length > 0) {
const selected = Array.from(e.target.files).filter((f) => f.type.startsWith("image/")); try {
if (selected.length > 0) { const selected = await open({
handleImagesUpload(selected); multiple: true,
filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg", "gif", "bmp", "webp"] }],
});
if (selected) {
const paths = Array.isArray(selected) ? selected : [selected];
if (paths.length > 0) {
handleImagesUpload(paths as unknown as File[]);
}
}
} catch (err) {
setError(`Attachment failed: ${String(err)}`);
} }
} }
}; };
@ -158,10 +180,27 @@ export default function LogUpload() {
setIsUploading(true); setIsUploading(true);
setError(null); setError(null);
try { try {
const uploaded = await Promise.all( const uploaded: ImageAttachment[] = await Promise.all(
imageFiles.map(async (file) => { imageFiles.map(async (file) => {
const result = await uploadImageAttachmentCmd(id, file.name); // Extract the path from the file name (which contains the full path from file dialog)
return result; const filePath = file.name;
// Get just the filename for display
const fileName = filePath.split(/[\/\\]/).pop() || "unknown";
// Use uploadLogFileCmd which properly handles file paths
const result = await uploadLogFileCmd(id, filePath);
// Convert LogFile to ImageAttachment for the state
return {
id: result.id,
issue_id: result.issue_id,
file_name: result.file_name,
file_path: result.file_path,
file_size: result.file_size,
mime_type: result.mime_type,
upload_hash: result.content_hash,
uploaded_at: result.uploaded_at,
pii_warning_acknowledged: true,
is_paste: false,
};
}) })
); );
setImages((prev) => [...prev, ...uploaded]); setImages((prev) => [...prev, ...uploaded]);

View File

@ -64,6 +64,7 @@ const emptyProvider: ProviderConfig = {
api_format: undefined, api_format: undefined,
session_id: undefined, session_id: undefined,
user_id: undefined, user_id: undefined,
use_datastore_upload: undefined,
}; };
export default function AIProviders() { export default function AIProviders() {
@ -456,11 +457,28 @@ export default function AIProviders() {
placeholder="user@example.com" placeholder="user@example.com"
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Optional: Email address for usage tracking. If omitted, costs are attributed to the API key owner. Prefix added before API key (e.g., "Bearer " for OpenAI, empty for Custom REST)
</p> </p>
</div> </div>
)} )}
{/* Datastore upload option */}
<div className="space-y-2">
<Label>Enable File Upload</Label>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={form.use_datastore_upload ?? false}
onChange={(e) => setForm({ ...form, use_datastore_upload: e.target.checked })}
className="w-4 h-4 rounded border-gray-300"
/>
<span className="text-sm">Enable file upload to datastore (for providers that support it)</span>
</div>
<p className="text-xs text-muted-foreground">
When enabled, files will be uploaded to the provider's datastore before chat messages. This allows the AI to analyze uploaded files.
</p>
</div>
{/* Custom REST specific: model dropdown with custom option */} {/* Custom REST specific: model dropdown with custom option */}
{form.api_format === CUSTOM_REST_FORMAT && ( {form.api_format === CUSTOM_REST_FORMAT && (
<div className="space-y-2"> <div className="space-y-2">