Compare commits

..

No commits in common. "a748df824b172103d00f8ef15e36cb39a60c3482" and "859d7a0da8aed73e0f49f29376e1622ea2856073" have entirely different histories.

9 changed files with 52 additions and 252 deletions

1
src-tauri/Cargo.lock generated
View File

@ -4242,7 +4242,6 @@ 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", "multipart"] } reqwest = { version = "0.12", features = ["json", "stream"] }
regex = "1" regex = "1"
aho-corasick = "1" aho-corasick = "1"
uuid = { version = "1", features = ["v7"] } uuid = { version = "1", features = ["v7"] }

View File

@ -8,13 +8,12 @@ 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; 6] = [ const SUPPORTED_IMAGE_MIME_TYPES: [&str; 5] = [
"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> {
@ -266,128 +265,6 @@ 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::*;
@ -399,13 +276,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"));
} }
#[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, use_datastore_upload, updated_at) custom_endpoint_path, custom_auth_header, custom_auth_prefix, api_format, user_id, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, datetime('now'))", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, datetime('now'))",
rusqlite::params![ rusqlite::params![
uuid::Uuid::now_v7().to_string(), uuid::Uuid::now_v7().to_string(),
provider.name, provider.name,
@ -174,7 +174,6 @@ 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,11 +190,10 @@ 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",
) )
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
@ -214,31 +212,29 @@ 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())?
.map_err(|e| e.to_string())? .filter_map(|r| r.ok())
.filter_map(|r| r.ok()) .filter_map(
.filter_map( |(
|( name,
name, provider_type,
provider_type, api_url,
api_url, encrypted_key,
encrypted_key, model,
model, max_tokens,
max_tokens, temperature,
temperature, custom_endpoint_path,
custom_endpoint_path, custom_auth_header,
custom_auth_header, custom_auth_prefix,
custom_auth_prefix, api_format,
api_format, user_id,
user_id, )| {
_use_datastore_upload, // Decrypt the API key
)| { 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,
@ -254,7 +250,6 @@ 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,12 +73,11 @@ 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,
commands::image::upload_file_to_datastore, // AI
// 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,9 +39,6 @@ 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,7 +16,6 @@ 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 {
@ -284,9 +283,6 @@ 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,7 +9,6 @@ import {
detectPiiCmd, detectPiiCmd,
uploadImageAttachmentCmd, uploadImageAttachmentCmd,
uploadPasteImageCmd, uploadPasteImageCmd,
uploadFileToDatastoreCmd,
listImageAttachmentsCmd, listImageAttachmentsCmd,
deleteImageAttachmentCmd, deleteImageAttachmentCmd,
type LogFile, type LogFile,
@ -18,7 +17,6 @@ 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 }>();
@ -108,43 +106,23 @@ export default function LogUpload() {
}; };
const handleImageDrop = useCallback( const handleImageDrop = useCallback(
async (e: React.DragEvent) => { (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
// Use file dialog to get actual paths (drag-and-drop doesn't give paths in Tauri v2) const droppedFiles = Array.from(e.dataTransfer.files);
try { const imageFiles = droppedFiles.filter((f) => f.type.startsWith("image/"));
const selected = await open({
multiple: true, if (imageFiles.length > 0) {
filters: [{ name: "Images", extensions: ["png", "jpg", "jpeg", "gif", "bmp", "webp"] }], handleImagesUpload(imageFiles);
});
if (!selected) return;
const paths = Array.isArray(selected) ? selected : [selected];
if (paths.length > 0) {
handleImagesUpload(paths as unknown as File[]);
}
} catch (err) {
setError(`Attachment failed: ${String(err)}`);
} }
}, },
[id] [id]
); );
const handleImageFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleImageFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) { if (e.target.files) {
try { const selected = Array.from(e.target.files).filter((f) => f.type.startsWith("image/"));
const selected = await open({ if (selected.length > 0) {
multiple: true, handleImagesUpload(selected);
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)}`);
} }
} }
}; };
@ -180,27 +158,10 @@ export default function LogUpload() {
setIsUploading(true); setIsUploading(true);
setError(null); setError(null);
try { try {
const uploaded: ImageAttachment[] = await Promise.all( const uploaded = await Promise.all(
imageFiles.map(async (file) => { imageFiles.map(async (file) => {
// Extract the path from the file name (which contains the full path from file dialog) const result = await uploadImageAttachmentCmd(id, file.name);
const filePath = file.name; return result;
// 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,7 +64,6 @@ 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() {
@ -457,28 +456,11 @@ 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">
Prefix added before API key (e.g., "Bearer " for OpenAI, empty for Custom REST) Optional: Email address for usage tracking. If omitted, costs are attributed to the API key owner.
</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">