feat: support GenAI datastore file uploads and fix paste image upload
Some checks failed
Test / frontend-tests (pull_request) Successful in 59s
Test / frontend-typecheck (pull_request) Successful in 1m5s
Test / rust-fmt-check (pull_request) Failing after 2m25s
Test / rust-clippy (pull_request) Failing after 18m25s
Test / rust-tests (pull_request) Successful in 19m42s

- Add use_datastore_upload field to ProviderConfig for enabling datastore uploads
- Add upload_file_to_datastore and upload_file_to_datastore_any commands
- Add upload_log_file_by_content and upload_image_attachment_by_content commands for drag-and-drop without file paths
- Add multipart/form-data support for file uploads to GenAI datastore
- Add support for image/bmp MIME type in image validation
- Add x-generic-api-key header support for GenAI API authentication

This addresses:
- Paste fails to attach screenshot (clipboard)
- File upload fails with 500 error when using GenAI API
- GenAI datastore upload endpoint support for non-text files
This commit is contained in:
Shaun Arman 2026-04-09 18:05:44 -05:00
parent 859d7a0da8
commit 420411882e
7 changed files with 436 additions and 38 deletions

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

@ -97,6 +97,72 @@ pub async fn upload_log_file(
Ok(log_file) Ok(log_file)
} }
#[tauri::command]
pub async fn upload_log_file_by_content(
issue_id: String,
file_name: String,
content: String,
state: State<'_, AppState>,
) -> Result<LogFile, String> {
let content_bytes = content.as_bytes();
let content_hash = format!("{:x}", Sha256::digest(content_bytes));
let file_size = content_bytes.len() as i64;
// Determine mime type based on file extension
let mime_type = if file_name.ends_with(".json") {
"application/json"
} else if file_name.ends_with(".xml") {
"application/xml"
} else {
"text/plain"
};
// Use the file_name as the file_path for DB storage
let log_file = LogFile::new(issue_id.clone(), file_name.clone(), file_name.clone(), file_size);
let log_file = LogFile {
content_hash: content_hash.clone(),
mime_type: mime_type.to_string(),
..log_file
};
let db = state.db.lock().map_err(|e| e.to_string())?;
db.execute(
"INSERT INTO log_files (id, issue_id, file_name, file_path, file_size, mime_type, content_hash, uploaded_at, redacted) \
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
rusqlite::params![
log_file.id,
log_file.issue_id,
log_file.file_name,
log_file.file_path,
log_file.file_size,
log_file.mime_type,
log_file.content_hash,
log_file.uploaded_at,
log_file.redacted as i32,
],
)
.map_err(|_| "Failed to store uploaded log metadata".to_string())?;
// Audit
let entry = AuditEntry::new(
"upload_log_file".to_string(),
"log_file".to_string(),
log_file.id.clone(),
serde_json::json!({ "issue_id": issue_id, "file_name": log_file.file_name }).to_string(),
);
if let Err(err) = crate::audit::log::write_audit_event(
&db,
&entry.action,
&entry.entity_type,
&entry.entity_id,
&entry.details,
) {
warn!(error = %err, "failed to write upload_log_file audit entry");
}
Ok(log_file)
}
#[tauri::command] #[tauri::command]
pub async fn detect_pii( pub async fn detect_pii(
log_file_id: String, log_file_id: String,

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> {
@ -122,6 +123,92 @@ pub async fn upload_image_attachment(
Ok(attachment) Ok(attachment)
} }
#[tauri::command]
pub async fn upload_image_attachment_by_content(
issue_id: String,
file_name: String,
base64_content: String,
state: State<'_, AppState>,
) -> Result<ImageAttachment, String> {
let data_part = base64_content
.split(',')
.nth(1)
.ok_or("Invalid image data format - missing base64 content")?;
let decoded = base64::engine::general_purpose::STANDARD
.decode(data_part)
.map_err(|_| "Failed to decode base64 image data")?;
let content_hash = format!("{:x}", sha2::Sha256::digest(&decoded));
let file_size = decoded.len() as i64;
let mime_type: String = infer::get(&decoded)
.map(|m| m.mime_type().to_string())
.unwrap_or_else(|| "image/png".to_string());
if !is_supported_image_format(mime_type.as_str()) {
return Err(format!(
"Unsupported image format: {}. Supported formats: {}",
mime_type,
SUPPORTED_IMAGE_MIME_TYPES.join(", ")
));
}
// Use the file_name as file_path for DB storage
let attachment = ImageAttachment::new(
issue_id.clone(),
file_name.clone(),
file_name,
file_size,
mime_type,
content_hash.clone(),
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 uploaded image metadata".to_string())?;
let entry = AuditEntry::new(
"upload_image_attachment".to_string(),
"image_attachment".to_string(),
attachment.id.clone(),
serde_json::json!({
"issue_id": issue_id,
"file_name": attachment.file_name,
"is_paste": false,
})
.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_image_attachment audit entry");
}
Ok(attachment)
}
#[tauri::command] #[tauri::command]
pub async fn upload_paste_image( pub async fn upload_paste_image(
issue_id: String, issue_id: String,
@ -265,6 +352,227 @@ pub async fn delete_image_attachment(
Ok(()) Ok(())
} }
#[tauri::command]
pub async fn upload_file_to_datastore(
provider_config: serde_json::Value,
file_path: String,
_state: State<'_, AppState>,
) -> Result<String, String> {
use reqwest::multipart::Form;
let canonical_path = validate_image_file_path(&file_path)?;
let content = std::fs::read(&canonical_path)
.map_err(|_| "Failed to read file for datastore upload")?;
let file_name = canonical_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
let _file_size = content.len() as i64;
// Extract API URL and auth header from provider config
let api_url = provider_config
.get("api_url")
.and_then(|v| v.as_str())
.ok_or("Provider config missing api_url")?
.to_string();
// Extract use_datastore_upload flag
let use_datastore = provider_config
.get("use_datastore_upload")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if !use_datastore {
return Err("use_datastore_upload is not enabled for this provider".to_string());
}
// Get datastore ID from custom_endpoint_path (stored as datastore ID)
let datastore_id = provider_config
.get("custom_endpoint_path")
.and_then(|v| v.as_str())
.ok_or("Provider config missing datastore ID in custom_endpoint_path")?
.to_string();
// Build upload endpoint: POST /api/v2/upload/<DATASTORE-ID>
let api_url = api_url.trim_end_matches('/');
let upload_url = format!("{}/upload/{}", api_url, datastore_id);
// Read auth header and value
let auth_header = provider_config
.get("custom_auth_header")
.and_then(|v| v.as_str())
.unwrap_or("x-generic-api-key");
let auth_prefix = provider_config
.get("custom_auth_prefix")
.and_then(|v| v.as_str())
.unwrap_or("");
let api_key = provider_config
.get("api_key")
.and_then(|v| v.as_str())
.ok_or("Provider config missing api_key")?;
let auth_value = format!("{}{}", auth_prefix, api_key);
let client = reqwest::Client::new();
// Create multipart form
let part = reqwest::multipart::Part::bytes(content)
.file_name(file_name)
.mime_str("application/octet-stream")
.map_err(|e| format!("Failed to create multipart part: {}", e))?;
let form = Form::new()
.part("file", part);
let resp = client
.post(&upload_url)
.header(auth_header, auth_value)
.multipart(form)
.send()
.await
.map_err(|e| format!("Upload request failed: {}", 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!("Datastore upload error {}: {}", status, text));
}
// Parse response to get file ID
let json = resp.json::<serde_json::Value>()
.await
.map_err(|e| format!("Failed to parse upload response: {}", e))?;
// Response should have file_id or id field
let file_id = json.get("file_id")
.or_else(|| json.get("id"))
.and_then(|v| v.as_str())
.ok_or_else(|| format!("Response missing file_id: {}", serde_json::to_string_pretty(&json).unwrap_or_default()))?
.to_string();
Ok(file_id)
}
/// Upload any file (not just images) to GenAI datastore
#[tauri::command]
pub async fn upload_file_to_datastore_any(
provider_config: serde_json::Value,
file_path: String,
_state: State<'_, AppState>,
) -> Result<String, String> {
use reqwest::multipart::Form;
// Validate file exists and is accessible
let path = Path::new(&file_path);
let canonical = std::fs::canonicalize(path).map_err(|_| "Unable to access selected file")?;
let metadata = std::fs::metadata(&canonical).map_err(|_| "Unable to read file metadata")?;
if !metadata.is_file() {
return Err("Selected path is not a file".to_string());
}
let content = std::fs::read(&canonical)
.map_err(|_| "Failed to read file for datastore upload")?;
let file_name = canonical
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
let _file_size = content.len() as i64;
// Extract API URL and auth header from provider config
let api_url = provider_config
.get("api_url")
.and_then(|v| v.as_str())
.ok_or("Provider config missing api_url")?
.to_string();
// Extract use_datastore_upload flag
let use_datastore = provider_config
.get("use_datastore_upload")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if !use_datastore {
return Err("use_datastore_upload is not enabled for this provider".to_string());
}
// Get datastore ID from custom_endpoint_path (stored as datastore ID)
let datastore_id = provider_config
.get("custom_endpoint_path")
.and_then(|v| v.as_str())
.ok_or("Provider config missing datastore ID in custom_endpoint_path")?
.to_string();
// Build upload endpoint: POST /api/v2/upload/<DATASTORE-ID>
let api_url = api_url.trim_end_matches('/');
let upload_url = format!("{}/upload/{}", api_url, datastore_id);
// Read auth header and value
let auth_header = provider_config
.get("custom_auth_header")
.and_then(|v| v.as_str())
.unwrap_or("x-generic-api-key");
let auth_prefix = provider_config
.get("custom_auth_prefix")
.and_then(|v| v.as_str())
.unwrap_or("");
let api_key = provider_config
.get("api_key")
.and_then(|v| v.as_str())
.ok_or("Provider config missing api_key")?;
let auth_value = format!("{}{}", auth_prefix, api_key);
let client = reqwest::Client::new();
// Create multipart form
let part = reqwest::multipart::Part::bytes(content)
.file_name(file_name)
.mime_str("application/octet-stream")
.map_err(|e| format!("Failed to create multipart part: {}", e))?;
let form = Form::new()
.part("file", part);
let resp = client
.post(&upload_url)
.header(auth_header, auth_value)
.multipart(form)
.send()
.await
.map_err(|e| format!("Upload request failed: {}", 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!("Datastore upload error {}: {}", status, text));
}
// Parse response to get file ID
let json = resp.json::<serde_json::Value>()
.await
.map_err(|e| format!("Failed to parse upload response: {}", e))?;
// Response should have file_id or id field
let file_id = json.get("file_id")
.or_else(|| json.get("id"))
.and_then(|v| v.as_str())
.ok_or_else(|| format!("Response missing file_id: {}", serde_json::to_string_pretty(&json).unwrap_or_default()))?
.to_string();
Ok(file_id)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -276,7 +584,7 @@ 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"));
} }
} }

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}"))?;
@ -191,7 +192,7 @@ 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, use_datastore_upload
FROM ai_providers FROM ai_providers
ORDER BY name", ORDER BY name",
) )
@ -201,38 +202,40 @@ pub async fn load_ai_providers(
.query_map([], |row| { .query_map([], |row| {
let encrypted_key: String = row.get(3)?; let encrypted_key: String = row.get(3)?;
Ok(( Ok((
row.get::<_, String>(0)?, // name row.get::<_, String>(0)?, // name
row.get::<_, String>(1)?, // provider_type row.get::<_, String>(1)?, // provider_type
row.get::<_, String>(2)?, // api_url row.get::<_, String>(2)?, // api_url
encrypted_key, // encrypted_api_key encrypted_key, // encrypted_api_key
row.get::<_, String>(4)?, // model row.get::<_, String>(4)?, // model
row.get::<_, Option<u32>>(5)?, // max_tokens row.get::<_, Option<u32>>(5)?, // max_tokens
row.get::<_, Option<f64>>(6)?, // temperature row.get::<_, Option<f64>>(6)?, // temperature
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,
use_datastore_upload,
)| {
// Decrypt the API key // Decrypt the API key
let api_key = crate::integrations::auth::decrypt_token(&encrypted_key).ok()?; let api_key = crate::integrations::auth::decrypt_token(&encrypted_key).ok()?;
@ -250,6 +253,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,
}) })
}, },
) )

View File

@ -71,12 +71,16 @@ pub fn run() {
commands::db::add_timeline_event, commands::db::add_timeline_event,
// Analysis / PII // Analysis / PII
commands::analysis::upload_log_file, commands::analysis::upload_log_file,
commands::analysis::upload_log_file_by_content,
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::upload_image_attachment_by_content,
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,
commands::image::upload_file_to_datastore,
commands::image::upload_file_to_datastore_any,
// AI // AI
commands::ai::analyze_logs, commands::ai::analyze_logs,
commands::ai::chat_message, commands::ai::chat_message,

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: When true, file uploads go to GenAI datastore instead of prompt
#[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 {
@ -277,9 +278,21 @@ export const listProvidersCmd = () => invoke<ProviderInfo[]>("list_providers");
export const uploadLogFileCmd = (issueId: string, filePath: string) => export const uploadLogFileCmd = (issueId: string, filePath: string) =>
invoke<LogFile>("upload_log_file", { issueId, filePath }); invoke<LogFile>("upload_log_file", { issueId, filePath });
export const uploadLogFileByContentCmd = (issueId: string, fileName: string, content: string) =>
invoke<LogFile>("upload_log_file_by_content", { issueId, fileName, content });
export const uploadImageAttachmentCmd = (issueId: string, filePath: string) => export const uploadImageAttachmentCmd = (issueId: string, filePath: string) =>
invoke<ImageAttachment>("upload_image_attachment", { issueId, filePath }); invoke<ImageAttachment>("upload_image_attachment", { issueId, filePath });
export const uploadImageAttachmentByContentCmd = (issueId: string, fileName: string, base64Content: string) =>
invoke<ImageAttachment>("upload_image_attachment_by_content", { issueId, fileName, base64Content });
export const uploadFileToDatastoreCmd = (providerConfig: ProviderConfig, filePath: string) =>
invoke<string>("upload_file_to_datastore", { providerConfig, filePath });
export const uploadFileToDatastoreAnyCmd = (providerConfig: ProviderConfig, filePath: string) =>
invoke<string>("upload_file_to_datastore_any", { providerConfig, filePath });
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 });