* Studio desktop: fix loading-toast overlap and typing lag on model load - Toaster: on desktop, offset toasts below the ~34px custom window titlebar (top 46 when isTauri) so they no longer cover the min/max/close controls. Web is unchanged (top 12). - Model load: the 2s load poll wrote loadProgress state every tick, which re-renders the whole chat page during "Starting model" (cheap in Chrome, janky in the desktop WebView2 -> laggy typing). That state is only read by the dismissed-toast inline status, so gate all four poll branches to write it only when the inline view is live; while the toast is up it updates via Sonner alone. * Studio desktop: fix HTML canvas preview, download, and panel offset - CSP: add frame-src for localhost/127.0.0.1 so the desktop webview can frame the backend-served artifact preview. default-src 'self' (no frame-src) blocked it -> "127.0.0.1 refused to connect"; web is same-origin so it already worked. - Download: route the canvas Download button through the native save dialog (downloadFile) instead of a blob-anchor click, which the Tauri WebView2 silently drops. - Nudge the artifact panel down 8px so its top edge/shadow isn't tucked under the window top bar. * Studio desktop: add HTML filter for native canvas save dialog Canvas Download saves .html via save_native_file, but save_filter() had no html/htm case, so the native dialog fell back to the JSON/CSV/etc filter and could block saving/browsing the .html export. Add an HTML filter and include html/htm in the catch-all. Addresses Codex review on #7391. * Studio desktop: unblock canvas preview in dev shell + clear header fade - Preview: the app CSP frame-src fix wasn't enough in the tauri dev shell. The preview endpoint sets its own frame-ancestors response header, which only allowed 'self' tauri://localhost http://tauri.localhost -- so the Vite dev origin (http://localhost:5173) was blocked and the frame stayed "refused to connect". Extend the allowlist with http://localhost:* and http://127.0.0.1:* (the endpoint only renders postMessage'd HTML in a no-same-origin sandbox, so it exposes no server resource). - Shadow: the artifact panel toolbar sat under the full-width chat-header-fade; lower the panel top (mt 80->90px) so the controls clear the fade. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
329 lines
12 KiB
Rust
329 lines
12 KiB
Rust
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
|
|
|
|
use serde::Serialize;
|
|
use std::fs::{self, File};
|
|
use std::io::{Read, Write};
|
|
use std::path::{Path, PathBuf};
|
|
use tauri::{AppHandle, WebviewWindow};
|
|
use tauri_plugin_dialog::DialogExt;
|
|
|
|
const MAX_CHAT_IMPORT_BYTES: u64 = 64 * 1024 * 1024;
|
|
const NATIVE_FILE_NAME_HEADER: &str = "x-unsloth-default-name";
|
|
const CHAT_IMPORT_EXTENSIONS: &[&str] = &["jsonl", "ndjson", "csv"];
|
|
|
|
#[derive(Debug, Serialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct NativeImportedFile {
|
|
name: String,
|
|
content: String,
|
|
}
|
|
|
|
fn default_file_name(suggested_name: &str) -> String {
|
|
Path::new(suggested_name)
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.filter(|name| !name.is_empty() && *name != "." && *name != "..")
|
|
.unwrap_or("unsloth-export.json")
|
|
.to_string()
|
|
}
|
|
fn decode_default_file_name(encoded_name: &str) -> Result<String, String> {
|
|
let bytes = BASE64
|
|
.decode(encoded_name)
|
|
.map_err(|_| "Invalid native export filename.".to_string())?;
|
|
let name =
|
|
String::from_utf8(bytes).map_err(|_| "Invalid native export filename.".to_string())?;
|
|
Ok(default_file_name(&name))
|
|
}
|
|
|
|
fn save_filter(file_name: &str) -> (&'static str, Vec<&'static str>) {
|
|
match Path::new(file_name)
|
|
.extension()
|
|
.and_then(|extension| extension.to_str())
|
|
.map(str::to_ascii_lowercase)
|
|
.as_deref()
|
|
{
|
|
Some("json") => ("JSON", vec!["json"]),
|
|
Some("jsonl") | Some("ndjson") => ("JSON Lines", vec!["jsonl", "ndjson"]),
|
|
Some("csv") => ("CSV", vec!["csv"]),
|
|
Some("md") | Some("markdown") => ("Markdown", vec!["md", "markdown"]),
|
|
Some("html") | Some("htm") => ("HTML", vec!["html", "htm"]),
|
|
Some("zip") => ("ZIP archive", vec!["zip"]),
|
|
_ => (
|
|
"Export files",
|
|
vec![
|
|
"json", "jsonl", "ndjson", "csv", "md", "markdown", "html", "htm", "zip",
|
|
],
|
|
),
|
|
}
|
|
}
|
|
|
|
fn local_dialog_path(path: tauri_plugin_dialog::FilePath) -> Result<PathBuf, String> {
|
|
path.into_path()
|
|
.map_err(|_| "Only local filesystem paths are supported.".to_string())
|
|
}
|
|
|
|
fn save_selected_file(
|
|
selected_path: Option<PathBuf>,
|
|
content: &[u8],
|
|
) -> Result<Option<String>, String> {
|
|
let Some(path) = selected_path else {
|
|
return Ok(None);
|
|
};
|
|
let parent = path
|
|
.parent()
|
|
.filter(|parent| !parent.as_os_str().is_empty())
|
|
.unwrap_or_else(|| Path::new("."));
|
|
let mut builder = tempfile::Builder::new();
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let permissions = fs::metadata(&path)
|
|
.map(|metadata| metadata.permissions())
|
|
.unwrap_or_else(|_| fs::Permissions::from_mode(0o666));
|
|
builder.permissions(permissions);
|
|
}
|
|
let mut temporary = builder
|
|
.prefix(".unsloth-export-")
|
|
.tempfile_in(parent)
|
|
.map_err(|error| format!("Failed to prepare {}: {error}", path.display()))?;
|
|
temporary
|
|
.write_all(content)
|
|
.and_then(|()| temporary.as_file().sync_all())
|
|
.map_err(|error| format!("Failed to save {}: {error}", path.display()))?;
|
|
temporary
|
|
.persist(&path)
|
|
.map_err(|error| format!("Failed to save {}: {}", path.display(), error.error))?;
|
|
let file_name = path
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.unwrap_or("export")
|
|
.to_string();
|
|
Ok(Some(file_name))
|
|
}
|
|
|
|
fn read_selected_import(
|
|
selected_path: Option<PathBuf>,
|
|
) -> Result<Option<NativeImportedFile>, String> {
|
|
let Some(path) = selected_path else {
|
|
return Ok(None);
|
|
};
|
|
let extension = path
|
|
.extension()
|
|
.and_then(|extension| extension.to_str())
|
|
.map(str::to_ascii_lowercase)
|
|
.ok_or_else(|| "Chat import must be a .jsonl, .ndjson, or .csv file.".to_string())?;
|
|
if !CHAT_IMPORT_EXTENSIONS.contains(&extension.as_str()) {
|
|
return Err("Chat import must be a .jsonl, .ndjson, or .csv file.".to_string());
|
|
}
|
|
|
|
let metadata = fs::metadata(&path)
|
|
.map_err(|error| format!("Failed to inspect {}: {error}", path.display()))?;
|
|
if !metadata.is_file() {
|
|
return Err(format!("Selected import is not a file: {}", path.display()));
|
|
}
|
|
if metadata.len() > MAX_CHAT_IMPORT_BYTES {
|
|
return Err(format!(
|
|
"Chat import is too large (maximum {} MiB).",
|
|
MAX_CHAT_IMPORT_BYTES / 1024 / 1024
|
|
));
|
|
}
|
|
|
|
// Limit the read too, so a file that grows after metadata inspection cannot
|
|
// make the command allocate without bound.
|
|
let file =
|
|
File::open(&path).map_err(|error| format!("Failed to open {}: {error}", path.display()))?;
|
|
let mut bytes = Vec::with_capacity(metadata.len() as usize);
|
|
file.take(MAX_CHAT_IMPORT_BYTES + 1)
|
|
.read_to_end(&mut bytes)
|
|
.map_err(|error| format!("Failed to read {}: {error}", path.display()))?;
|
|
if bytes.len() as u64 > MAX_CHAT_IMPORT_BYTES {
|
|
return Err(format!(
|
|
"Chat import is too large (maximum {} MiB).",
|
|
MAX_CHAT_IMPORT_BYTES / 1024 / 1024
|
|
));
|
|
}
|
|
let content = String::from_utf8(bytes)
|
|
.map_err(|_| format!("Chat import is not valid UTF-8: {}", path.display()))?;
|
|
let name = path
|
|
.file_name()
|
|
.and_then(|name| name.to_str())
|
|
.map(str::to_string)
|
|
.unwrap_or_else(|| format!("chat-import.{extension}"));
|
|
Ok(Some(NativeImportedFile { name, content }))
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn save_native_file(
|
|
window: WebviewWindow,
|
|
app: AppHandle,
|
|
request: tauri::ipc::Request<'_>,
|
|
) -> Result<Option<String>, String> {
|
|
crate::native_intents::ensure_main_window(&window)?;
|
|
let encoded_name = request
|
|
.headers()
|
|
.get(NATIVE_FILE_NAME_HEADER)
|
|
.ok_or_else(|| "Native export filename is missing.".to_string())?
|
|
.to_str()
|
|
.map_err(|_| "Invalid native export filename.".to_string())?;
|
|
let file_name = decode_default_file_name(encoded_name)?;
|
|
let content = match request.body() {
|
|
tauri::ipc::InvokeBody::Raw(content) => content,
|
|
_ => return Err("Native export content must be binary.".to_string()),
|
|
};
|
|
let (filter_name, extensions) = save_filter(&file_name);
|
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
|
app.dialog()
|
|
.file()
|
|
.set_title("Save Unsloth export")
|
|
.set_file_name(file_name)
|
|
.add_filter(filter_name, &extensions)
|
|
.save_file(move |path| {
|
|
let _ = tx.send(path);
|
|
});
|
|
let selected_path = rx
|
|
.await
|
|
.map_err(|_| "Save dialog closed unexpectedly.".to_string())?
|
|
.map(local_dialog_path)
|
|
.transpose()?;
|
|
save_selected_file(selected_path, content)
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn pick_native_chat_import(
|
|
window: WebviewWindow,
|
|
app: AppHandle,
|
|
) -> Result<Option<NativeImportedFile>, String> {
|
|
crate::native_intents::ensure_main_window(&window)?;
|
|
let (tx, rx) = tokio::sync::oneshot::channel();
|
|
app.dialog()
|
|
.file()
|
|
.set_title("Import chats")
|
|
.add_filter("Chat exports", CHAT_IMPORT_EXTENSIONS)
|
|
.pick_file(move |path| {
|
|
let _ = tx.send(path);
|
|
});
|
|
let selected_path = rx
|
|
.await
|
|
.map_err(|_| "Import dialog closed unexpectedly.".to_string())?
|
|
.map(local_dialog_path)
|
|
.transpose()?;
|
|
read_selected_import(selected_path)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
fn temp_path(name: &str) -> PathBuf {
|
|
let nanos = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap()
|
|
.as_nanos();
|
|
std::env::temp_dir().join(format!(
|
|
"unsloth-native-files-{name}-{}-{nanos}",
|
|
std::process::id()
|
|
))
|
|
}
|
|
|
|
#[test]
|
|
fn cancellation_is_quiet_for_save_and_import() {
|
|
assert!(save_selected_file(None, b"x").unwrap().is_none());
|
|
assert!(read_selected_import(None).unwrap().is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn writes_text_and_binary_exactly() {
|
|
// Overwriting must stage the new content before replacing the destination.
|
|
let text_path = temp_path("text").with_extension("json");
|
|
let binary_path = temp_path("binary").with_extension("zip");
|
|
|
|
fs::write(&text_path, b"previous export").unwrap();
|
|
save_selected_file(Some(text_path.clone()), b"{\"ok\":true}").unwrap();
|
|
save_selected_file(Some(binary_path.clone()), &[0, 1, 2, 255]).unwrap();
|
|
assert_eq!(fs::read(&text_path).unwrap(), b"{\"ok\":true}");
|
|
assert_eq!(fs::read(&binary_path).unwrap(), [0, 1, 2, 255]);
|
|
let _ = fs::remove_file(text_path);
|
|
let _ = fs::remove_file(binary_path);
|
|
}
|
|
|
|
#[test]
|
|
fn markdown_exports_use_a_markdown_save_filter() {
|
|
assert_eq!(
|
|
save_filter("message.md"),
|
|
("Markdown", vec!["md", "markdown"])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn html_canvas_exports_use_an_html_save_filter() {
|
|
assert_eq!(save_filter("canvas.html"), ("HTML", vec!["html", "htm"]));
|
|
assert_eq!(save_filter("canvas.HTM"), ("HTML", vec!["html", "htm"]));
|
|
}
|
|
|
|
#[test]
|
|
fn reads_supported_import_and_rejects_other_extensions() {
|
|
let jsonl_path = temp_path("allowed").with_extension("JSONL");
|
|
fs::write(&jsonl_path, "{\"messages\":[]}").unwrap();
|
|
let imported = read_selected_import(Some(jsonl_path.clone()))
|
|
.unwrap()
|
|
.unwrap();
|
|
assert_eq!(imported.content, "{\"messages\":[]}");
|
|
|
|
let json_path = temp_path("unsupported").with_extension("json");
|
|
fs::write(&json_path, "{}").unwrap();
|
|
assert!(read_selected_import(Some(json_path.clone())).is_err());
|
|
let txt_path = temp_path("denied").with_extension("txt");
|
|
fs::write(&txt_path, "no").unwrap();
|
|
assert!(read_selected_import(Some(txt_path.clone()))
|
|
.unwrap_err()
|
|
.contains(".json"));
|
|
let _ = fs::remove_file(jsonl_path);
|
|
let _ = fs::remove_file(json_path);
|
|
let _ = fs::remove_file(txt_path);
|
|
}
|
|
|
|
#[test]
|
|
fn read_limit_and_utf8_errors_are_concrete() {
|
|
let oversized = temp_path("oversized").with_extension("csv");
|
|
let file = File::create(&oversized).unwrap();
|
|
file.set_len(MAX_CHAT_IMPORT_BYTES + 1).unwrap();
|
|
assert!(read_selected_import(Some(oversized.clone()))
|
|
.unwrap_err()
|
|
.contains("too large"));
|
|
|
|
let invalid = temp_path("invalid-utf8").with_extension("jsonl");
|
|
fs::write(&invalid, [0xff]).unwrap();
|
|
assert!(read_selected_import(Some(invalid.clone()))
|
|
.unwrap_err()
|
|
.contains("UTF-8"));
|
|
let _ = fs::remove_file(oversized);
|
|
let _ = fs::remove_file(invalid);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn non_utf8_import_name_preserves_csv_extension() {
|
|
use std::ffi::OsString;
|
|
use std::os::unix::ffi::OsStringExt;
|
|
|
|
let path = std::env::temp_dir().join(OsString::from_vec(vec![
|
|
b'u', b'n', b's', b'l', b'o', b't', b'h', 0xff, b'.', b'c', b's', b'v',
|
|
]));
|
|
fs::write(&path, "role,content\nuser,hello\n").unwrap();
|
|
let imported = read_selected_import(Some(path.clone())).unwrap().unwrap();
|
|
assert_eq!(imported.name, "chat-import.csv");
|
|
let _ = fs::remove_file(path);
|
|
}
|
|
|
|
#[test]
|
|
fn strips_directories_from_suggested_default_name() {
|
|
assert_eq!(default_file_name("../../chat.jsonl"), "chat.jsonl");
|
|
assert_eq!(default_file_name(""), "unsloth-export.json");
|
|
|
|
assert_eq!(
|
|
decode_default_file_name("Y2hhdC5qc29ubA==").unwrap(),
|
|
"chat.jsonl"
|
|
);
|
|
}
|
|
}
|