Merge pull request 'feat: support GenAI datastore file uploads and fix paste image upload' (#32) from bug/image-upload-secure into master
All checks were successful
Auto Tag / autotag (push) Successful in 7s
Auto Tag / wiki-sync (push) Successful in 6s
Auto Tag / build-macos-arm64 (push) Successful in 2m31s
Auto Tag / build-windows-amd64 (push) Successful in 14m10s
Auto Tag / build-linux-amd64 (push) Successful in 27m47s
Auto Tag / build-linux-arm64 (push) Successful in 28m14s

Reviewed-on: #32
This commit is contained in:
sarman 2026-04-10 02:22:42 +00:00
commit 46c48fb4a3
27 changed files with 3651 additions and 58 deletions

26
.eslintrc.json Normal file
View File

@ -0,0 +1,26 @@
{
"extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:react/recommended", "plugin:react-hooks/recommended"],
"parser": "@typescript-eslint/parser",
"parserOptions": {
"ecmaFeatures": {
"jsx": true
},
"ecmaVersion": "latest",
"sourceType": "module",
"project": ["./tsconfig.json"]
},
"plugins": ["@typescript-eslint", "react", "react-hooks"],
"settings": {
"react": {
"version": "detect"
}
},
"rules": {
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
"no-console": ["warn", { "allow": ["warn", "error"] }],
"react/react-in-jsx-scope": "off",
"react/prop-types": "off"
},
"ignorePatterns": ["dist/", "node_modules/", "src-tauri/", "target/", "coverage/"]
}

View File

@ -8,6 +8,7 @@
| Frontend only (port 1420) | `npm run dev` |
| Frontend production build | `npm run build` |
| Rust fmt check | `cargo fmt --manifest-path src-tauri/Cargo.toml --check` |
| Rust fmt fix | `cargo fmt --manifest-path src-tauri/Cargo.toml` |
| Rust clippy | `cargo clippy --manifest-path src-tauri/Cargo.toml -- -D warnings` |
| Rust tests | `cargo test --manifest-path src-tauri/Cargo.toml -- --test-threads=1` |
| Rust single test module | `cargo test --manifest-path src-tauri/Cargo.toml -- --test-threads=1 pii::detector` |
@ -16,6 +17,9 @@
| Frontend test (watch) | `npm run test` |
| Frontend coverage | `npm run test:coverage` |
| TypeScript type check | `npx tsc --noEmit` |
| Frontend lint | `npx eslint . --quiet` |
**Lint Policy**: **ALWAYS run `cargo fmt` and `cargo clippy` after any Rust code change**. Fix all issues before proceeding.
**Note**: The build runs `npm run build` before Rust build (via `beforeBuildCommand` in `tauri.conf.json`). This ensures TS is type-checked before packaging.

142
eslint.config.js Normal file
View File

@ -0,0 +1,142 @@
import globals from "globals";
import pluginReact from "eslint-plugin-react";
import pluginReactHooks from "eslint-plugin-react-hooks";
import pluginTs from "@typescript-eslint/eslint-plugin";
import parserTs from "@typescript-eslint/parser";
export default [
{
files: ["src/**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: {
...globals.browser,
...globals.node,
},
parser: parserTs,
parserOptions: {
ecmaFeatures: {
jsx: true,
},
project: "./tsconfig.json",
},
},
plugins: {
react: pluginReact,
"react-hooks": pluginReactHooks,
"@typescript-eslint": pluginTs,
},
settings: {
react: {
version: "detect",
},
},
rules: {
...pluginReact.configs.recommended.rules,
...pluginReactHooks.configs.recommended.rules,
...pluginTs.configs.recommended.rules,
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"no-console": ["warn", { allow: ["warn", "error"] }],
"react/react-in-jsx-scope": "off",
"react/prop-types": "off",
"react/no-unescaped-entities": "off",
},
},
{
files: ["tests/unit/**/*.test.{ts,tsx}"],
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: {
...globals.browser,
...globals.node,
...globals.vitest,
},
parser: parserTs,
parserOptions: {
ecmaFeatures: {
jsx: true,
},
project: "./tsconfig.json",
},
},
plugins: {
react: pluginReact,
"react-hooks": pluginReactHooks,
"@typescript-eslint": pluginTs,
},
settings: {
react: {
version: "detect",
},
},
rules: {
...pluginReact.configs.recommended.rules,
...pluginReactHooks.configs.recommended.rules,
...pluginTs.configs.recommended.rules,
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"no-console": ["warn", { allow: ["warn", "error"] }],
"react/react-in-jsx-scope": "off",
"react/prop-types": "off",
"react/no-unescaped-entities": "off",
},
},
{
files: ["tests/e2e/**/*.ts", "tests/e2e/**/*.tsx"],
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: {
...globals.node,
},
parser: parserTs,
parserOptions: {
ecmaFeatures: {
jsx: false,
},
},
},
plugins: {
"@typescript-eslint": pluginTs,
},
rules: {
...pluginTs.configs.recommended.rules,
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"no-console": ["warn", { allow: ["warn", "error"] }],
},
},
{
files: ["cli/**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: {
...globals.node,
},
parser: parserTs,
parserOptions: {
ecmaFeatures: {
jsx: false,
},
},
},
plugins: {
"@typescript-eslint": pluginTs,
},
rules: {
...pluginTs.configs.recommended.rules,
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"no-console": ["warn", { allow: ["warn", "error"] }],
"react/no-unescaped-entities": "off",
},
},
{
files: ["**/*.ts", "**/*.tsx"],
ignores: ["dist/", "node_modules/", "src-tauri/", "target/", "coverage/", "tailwind.config.ts"],
},
];

3006
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -36,13 +36,18 @@
"@testing-library/react": "^16",
"@testing-library/user-event": "^14",
"@types/react": "^18",
"@types/testing-library__react": "^10",
"@types/react-dom": "^18",
"@types/testing-library__react": "^10",
"@typescript-eslint/eslint-plugin": "^8.58.1",
"@typescript-eslint/parser": "^8.58.1",
"@vitejs/plugin-react": "^4",
"@vitest/coverage-v8": "^2",
"@wdio/cli": "^9",
"@wdio/mocha-framework": "^9",
"autoprefixer": "^10",
"eslint": "^9.39.4",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"jsdom": "^26",
"postcss": "^8",
"typescript": "^5",

1
src-tauri/Cargo.lock generated
View File

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

View File

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

View File

@ -97,6 +97,77 @@ pub async fn upload_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]
pub async fn detect_pii(
log_file_id: String,

View File

@ -8,12 +8,13 @@ use crate::db::models::{AuditEntry, ImageAttachment};
use crate::state::AppState;
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/jpeg",
"image/gif",
"image/webp",
"image/svg+xml",
"image/bmp",
];
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)
}
#[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]
pub async fn upload_paste_image(
issue_id: String,
@ -265,6 +352,245 @@ pub async fn delete_image_attachment(
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!("{api_url}/upload/{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!("{api_url}/upload/{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)]
mod tests {
use super::*;
@ -276,7 +602,7 @@ mod tests {
assert!(is_supported_image_format("image/gif"));
assert!(is_supported_image_format("image/webp"));
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"));
}
}

View File

@ -158,8 +158,8 @@ pub async fn save_ai_provider(
db.execute(
"INSERT OR REPLACE INTO ai_providers
(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)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, datetime('now'))",
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, ?14, datetime('now'))",
rusqlite::params![
uuid::Uuid::now_v7().to_string(),
provider.name,
@ -174,6 +174,7 @@ pub async fn save_ai_provider(
provider.custom_auth_prefix,
provider.api_format,
provider.user_id,
provider.use_datastore_upload,
],
)
.map_err(|e| format!("Failed to save AI provider: {e}"))?;
@ -191,7 +192,7 @@ pub async fn load_ai_providers(
let mut stmt = db
.prepare(
"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
ORDER BY name",
)
@ -214,6 +215,7 @@ pub async fn load_ai_providers(
row.get::<_, Option<String>>(9)?, // custom_auth_prefix
row.get::<_, Option<String>>(10)?, // api_format
row.get::<_, Option<String>>(11)?, // user_id
row.get::<_, Option<bool>>(12)?, // use_datastore_upload
))
})
.map_err(|e| e.to_string())?
@ -232,6 +234,7 @@ pub async fn load_ai_providers(
custom_auth_prefix,
api_format,
user_id,
use_datastore_upload,
)| {
// Decrypt the API key
let api_key = crate::integrations::auth::decrypt_token(&encrypted_key).ok()?;
@ -250,6 +253,7 @@ pub async fn load_ai_providers(
api_format,
session_id: None, // Session IDs are not persisted
user_id,
use_datastore_upload,
})
},
)

View File

@ -71,12 +71,16 @@ pub fn run() {
commands::db::add_timeline_event,
// Analysis / PII
commands::analysis::upload_log_file,
commands::analysis::upload_log_file_by_content,
commands::analysis::detect_pii,
commands::analysis::apply_redactions,
commands::image::upload_image_attachment,
commands::image::upload_image_attachment_by_content,
commands::image::list_image_attachments,
commands::image::delete_image_attachment,
commands::image::upload_paste_image,
commands::image::upload_file_to_datastore,
commands::image::upload_file_to_datastore_any,
// AI
commands::ai::analyze_logs,
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)
#[serde(skip_serializing_if = "Option::is_none")]
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)]

View File

@ -47,7 +47,7 @@ export default function App() {
const [collapsed, setCollapsed] = useState(false);
const [appVersion, setAppVersion] = useState("");
const { theme, setTheme, setProviders, getActiveProvider } = useSettingsStore();
const location = useLocation();
void useLocation();
useEffect(() => {
getVersion().then(setAppVersion).catch(() => {});

View File

@ -67,7 +67,7 @@ export function ImageGallery({ images, onDelete, showWarning = true }: ImageGall
)}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{images.map((image, idx) => (
{images.map((image) => (
<div key={image.id} className="group relative rounded-lg overflow-hidden bg-gray-100 border border-gray-200">
<button
onClick={() => {

View File

@ -1,4 +1,4 @@
import React from "react";
import React, { HTMLAttributes } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { clsx, type ClassValue } from "clsx";
@ -6,6 +6,26 @@ function cn(...inputs: ClassValue[]) {
return clsx(inputs);
}
// ─── Separator (ForwardRef) ───────────────────────────────────────────────────
export const Separator = React.forwardRef<
HTMLDivElement,
HTMLAttributes<HTMLDivElement> & { orientation?: "horizontal" | "vertical" }
>(({ className, orientation = "horizontal", ...props }, ref) => (
<div
ref={ref}
role="separator"
aria-orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
));
Separator.displayName = "Separator";
// ─── Button ──────────────────────────────────────────────────────────────────
const buttonVariants = cva(
@ -108,7 +128,7 @@ CardFooter.displayName = "CardFooter";
// ─── Input ───────────────────────────────────────────────────────────────────
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => (
@ -127,7 +147,7 @@ Input.displayName = "Input";
// ─── Label ───────────────────────────────────────────────────────────────────
export interface LabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {}
export type LabelProps = React.LabelHTMLAttributes<HTMLLabelElement>
export const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
({ className, ...props }, ref) => (
@ -145,7 +165,7 @@ Label.displayName = "Label";
// ─── Textarea ────────────────────────────────────────────────────────────────
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
export type TextareaProps = React.TextareaHTMLAttributes<HTMLTextAreaElement>
export const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => (
@ -320,28 +340,7 @@ export function Progress({ value = 0, max = 100, className, ...props }: Progress
);
}
// ─── Separator ───────────────────────────────────────────────────────────────
interface SeparatorProps extends React.HTMLAttributes<HTMLDivElement> {
orientation?: "horizontal" | "vertical";
}
export function Separator({
orientation = "horizontal",
className,
...props
}: SeparatorProps) {
return (
<div
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
);
}
// ─── RadioGroup ──────────────────────────────────────────────────────────────

View File

@ -16,6 +16,7 @@ export interface ProviderConfig {
api_format?: string;
session_id?: string;
user_id?: string;
use_datastore_upload?: boolean;
}
export interface Message {
@ -277,9 +278,21 @@ export const listProvidersCmd = () => invoke<ProviderInfo[]>("list_providers");
export const uploadLogFileCmd = (issueId: string, filePath: string) =>
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) =>
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) =>
invoke<ImageAttachment>("upload_paste_image", { issueId, base64Image, mimeType });

View File

@ -3,8 +3,6 @@ import { useNavigate } from "react-router-dom";
import { Search, Download, ExternalLink } from "lucide-react";
import {
Card,
CardHeader,
CardTitle,
CardContent,
Button,
Input,

View File

@ -1,4 +1,4 @@
import React, { useState, useCallback, useRef, useEffect } from "react";
import React, { useState, useCallback, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Upload, File, Trash2, ShieldCheck, AlertTriangle, Image as ImageIcon } from "lucide-react";
import { Button, Card, CardHeader, CardTitle, CardContent, Badge } from "@/components/ui";
@ -30,8 +30,6 @@ export default function LogUpload() {
const [isDetecting, setIsDetecting] = useState(false);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
@ -60,7 +58,7 @@ export default function LogUpload() {
const uploaded = await Promise.all(
files.map(async (entry) => {
if (entry.uploaded) return entry;
const content = await entry.file.text();
void await entry.file.text();
const logFile = await uploadLogFileCmd(id, entry.file.name);
return { ...entry, uploaded: logFile };
})
@ -129,8 +127,8 @@ export default function LogUpload() {
const handlePaste = useCallback(
async (e: React.ClipboardEvent) => {
const items = e.clipboardData?.items;
const imageItems = items ? Array.from(items).filter((item: DataTransferItem) => item.type.startsWith("image/")) : [];
void e.clipboardData?.items;
const imageItems = Array.from(e.clipboardData?.items || []).filter((item: DataTransferItem) => item.type.startsWith("image/"));
for (const item of imageItems) {
const file = item.getAsFile();
@ -181,14 +179,7 @@ export default function LogUpload() {
}
};
const fileToBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = (err) => reject(err);
reader.readAsDataURL(file);
});
};
const allUploaded = files.length > 0 && files.every((f) => f.uploaded);

View File

@ -66,7 +66,7 @@ export default function NewIssue() {
useEffect(() => {
const hasAcceptedDisclaimer = localStorage.getItem("tftsr-ai-disclaimer-accepted");
if (!hasAcceptedDisclaimer) {
setShowDisclaimer(true);
localStorage.setItem("tftsr-ai-disclaimer-accepted", "true");
}
}, []);

View File

@ -13,7 +13,7 @@ import {
export default function Postmortem() {
const { id } = useParams<{ id: string }>();
const getActiveProvider = useSettingsStore((s) => s.getActiveProvider);
void useSettingsStore((s) => s.getActiveProvider);
const [doc, setDoc] = useState<Document_ | null>(null);
const [content, setContent] = useState("");

View File

@ -14,7 +14,7 @@ import {
export default function RCA() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const getActiveProvider = useSettingsStore((s) => s.getActiveProvider);
void useSettingsStore((s) => s.getActiveProvider);
const [doc, setDoc] = useState<Document_ | null>(null);
const [content, setContent] = useState("");

View File

@ -6,7 +6,6 @@ import {
CardTitle,
CardContent,
Badge,
Separator,
} from "@/components/ui";
import { getAuditLogCmd, type AuditEntry } from "@/lib/tauriCommands";
import { useSettingsStore } from "@/stores/settingsStore";

View File

@ -1,4 +1,4 @@
import { waitForApp, clickByText } from "../helpers/app";
import { waitForApp } from "../helpers/app";
describe("Log Upload Flow", () => {
before(async () => {

View File

@ -1,5 +1,5 @@
import { join } from "path";
import { spawn, spawnSync } from "child_process";
import { spawn } from "child_process";
import type { Options } from "@wdio/types";
// Path to the tauri-driver binary

View File

@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import Security from "@/pages/Settings/Security";
import * as tauriCommands from "@/lib/tauriCommands";

View File

@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import History from "@/pages/History";
import { useHistoryStore } from "@/stores/historyStore";

View File

@ -32,6 +32,7 @@ vi.mock("@tauri-apps/plugin-fs", () => ({
exists: vi.fn(() => Promise.resolve(false)),
}));
// Mock console.error to suppress React warnings
const originalError = console.error;
beforeAll(() => {
console.error = (...args: unknown[]) => {