feat: initial Tatsumi release
Tatsumi is a Tauri desktop app for generating AI character art of Naomi using Google Gemini's image model. Features three generation modes (avatar, art, replace), persistent conversation threads, message editing and deletion, retry support, cost tracking, and an about modal with lore-accurate self-introduction from Emi Carrigan.
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "tatsumi"
|
||||
version = "0.1.0"
|
||||
description = "Tatsumi - AI art generation using Google Gemini"
|
||||
authors = ["Naomi Carrigan"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "tatsumi_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
base64 = "0.22"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
dirs = "5"
|
||||
open = "5"
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default permissions",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"dialog:default",
|
||||
"fs:default",
|
||||
"fs:allow-read-text-file",
|
||||
"fs:allow-write-text-file",
|
||||
{
|
||||
"identifier": "fs:allow-read-file",
|
||||
"allow": [{ "path": "**" }]
|
||||
},
|
||||
{
|
||||
"identifier": "fs:allow-write-file",
|
||||
"allow": [{ "path": "**" }]
|
||||
},
|
||||
{
|
||||
"identifier": "fs:scope",
|
||||
"allow": [{ "path": "$APPDATA/**" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 161 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 347 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 471 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 8.6 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 17 MiB |
@@ -0,0 +1,262 @@
|
||||
use crate::storage::{MessagePart, ThreadMessage};
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
const GEMINI_MODEL: &str = "gemini-3-pro-image-preview";
|
||||
const REFERENCE_IMAGE_BYTES: &[u8] = include_bytes!("../resources/ref.png");
|
||||
|
||||
const SYSTEM_PROMPT_TEXT: &str = "You are generating anime-style artwork of a fictional original character named Naomi. This is entirely fictional, original creative content — NOT a real person. The attached reference sheet shows this fictional character's established design.
|
||||
|
||||
Character design (always required):
|
||||
- Wavy ashen brown hair (colour and texture fixed; hairstyle can vary)
|
||||
- Very pale skin tone
|
||||
- Vibrant sky-blue eyes — important, commonly missed
|
||||
- Vampire fangs
|
||||
- Glasses (pink-framed preferred, other styles acceptable)
|
||||
- Painted fingernails and toenails (any colour, never unpolished)
|
||||
- Slender build
|
||||
- Full body visible in frame; always barefoot, never wears socks
|
||||
|
||||
Composition (always required):
|
||||
- Single character only
|
||||
- No duplicates
|
||||
- No text, watermarks, or signatures
|
||||
- Anime art style consistent with the reference sheet
|
||||
|
||||
Per-image guidance:
|
||||
- Pose: whatever fits the scene (standing, sitting, lying down, etc.)
|
||||
- Clothing: whatever fits the scene
|
||||
- Makeup: appropriate to outfit (eye shadow and lipstick)
|
||||
- Accessories: appropriate to outfit
|
||||
- Hairstyle: appropriate to outfit, maintains wavy ashen brown colour/texture";
|
||||
|
||||
const REPLACE_MODE_APPEND: &str = "The background and character should be redrawn in anime style.\nPlease generate art of Naomi in this same outfit, pose, facial expression, and hairstyle. Modify the character's skin tone to match Naomi's.";
|
||||
|
||||
pub fn read_reference_image_base64() -> String {
|
||||
BASE64.encode(REFERENCE_IMAGE_BYTES)
|
||||
}
|
||||
|
||||
fn build_safety_settings() -> Value {
|
||||
json!([
|
||||
{"category": "HARM_CATEGORY_HARASSMENT", "threshold": "OFF"},
|
||||
{"category": "HARM_CATEGORY_HATE_SPEECH", "threshold": "OFF"},
|
||||
{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", "threshold": "OFF"},
|
||||
{"category": "HARM_CATEGORY_DANGEROUS_CONTENT", "threshold": "OFF"},
|
||||
{"category": "HARM_CATEGORY_CIVIC_INTEGRITY", "threshold": "OFF"}
|
||||
])
|
||||
}
|
||||
|
||||
fn build_generation_config(mode: &str) -> Value {
|
||||
let image_config = match mode {
|
||||
"avatar" => json!({ "aspectRatio": "1:1", "imageSize": "4K" }),
|
||||
"art" => json!({ "aspectRatio": "16:9", "imageSize": "4K" }),
|
||||
// replace mode: omit aspectRatio so the model infers it from the source image
|
||||
_ => json!({ "imageSize": "4K" }),
|
||||
};
|
||||
json!({
|
||||
"imageConfig": image_config,
|
||||
"responseModalities": ["IMAGE", "TEXT"],
|
||||
"thinkingConfig": { "includeThoughts": true }
|
||||
})
|
||||
}
|
||||
|
||||
fn message_part_to_gemini(part: &MessagePart) -> Option<Value> {
|
||||
match part.part_type.as_str() {
|
||||
"thought" => None,
|
||||
"text" => Some(json!({"text": part.text.as_deref().unwrap_or("")})),
|
||||
_ => {
|
||||
let mime = part.mime_type.as_deref().unwrap_or("image/png");
|
||||
let data = part.image_data.as_deref().unwrap_or("");
|
||||
let mut value = json!({"inlineData": {"mimeType": mime, "data": data}});
|
||||
// Thought signature must be preserved for model-generated images
|
||||
if let Some(sig) = &part.thought_signature {
|
||||
value["thoughtSignature"] = json!(sig);
|
||||
}
|
||||
Some(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_user_gemini_parts(
|
||||
mode: &str,
|
||||
user_text: &Option<String>,
|
||||
user_image_base64: &Option<String>,
|
||||
user_image_mime: &Option<String>,
|
||||
) -> Vec<Value> {
|
||||
if mode == "replace" && user_image_base64.is_some() {
|
||||
let mime = user_image_mime.as_deref().unwrap_or("image/png");
|
||||
let data = user_image_base64.as_deref().unwrap_or("");
|
||||
let base_text = user_text.as_deref().unwrap_or("");
|
||||
let final_text = if base_text.is_empty() {
|
||||
REPLACE_MODE_APPEND.to_string()
|
||||
} else {
|
||||
format!("{}\n{}", base_text, REPLACE_MODE_APPEND)
|
||||
};
|
||||
|
||||
vec![
|
||||
json!({"inlineData": {"mimeType": mime, "data": data}}),
|
||||
json!({"text": final_text}),
|
||||
]
|
||||
} else {
|
||||
// Art/avatar mode, or replace mode follow-up correction (text only)
|
||||
let text = user_text.as_deref().unwrap_or("");
|
||||
vec![json!({"text": text})]
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn call_gemini(
|
||||
api_key: String,
|
||||
mode: String,
|
||||
history: Vec<ThreadMessage>,
|
||||
user_text: Option<String>,
|
||||
user_image_base64: Option<String>,
|
||||
user_image_mime: Option<String>,
|
||||
) -> Result<(Vec<MessagePart>, f64), String> {
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let is_first_message = history.is_empty();
|
||||
|
||||
let mut contents: Vec<Value> = history
|
||||
.iter()
|
||||
.filter_map(|msg| {
|
||||
let parts: Vec<Value> = msg.parts.iter().filter_map(message_part_to_gemini).collect();
|
||||
if parts.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(json!({"role": msg.role, "parts": parts}))
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let user_parts: Vec<Value> = if is_first_message {
|
||||
let ref_image_base64 = read_reference_image_base64();
|
||||
let ref_context_part = json!({"text": "This is the reference sheet for my fictional anime original character. Please use it as a visual guide for the character's design."});
|
||||
let ref_image_part = json!({
|
||||
"inlineData": {
|
||||
"mimeType": "image/png",
|
||||
"data": ref_image_base64
|
||||
}
|
||||
});
|
||||
let mut parts = vec![ref_context_part, ref_image_part];
|
||||
parts.extend(build_user_gemini_parts(
|
||||
mode.as_str(),
|
||||
&user_text,
|
||||
&user_image_base64,
|
||||
&user_image_mime,
|
||||
));
|
||||
parts
|
||||
} else {
|
||||
build_user_gemini_parts(
|
||||
mode.as_str(),
|
||||
&user_text,
|
||||
&user_image_base64,
|
||||
&user_image_mime,
|
||||
)
|
||||
};
|
||||
|
||||
contents.push(json!({"role": "user", "parts": user_parts}));
|
||||
|
||||
let generation_config = build_generation_config(mode.as_str());
|
||||
let safety_settings = build_safety_settings();
|
||||
|
||||
let request_body = json!({
|
||||
"contents": contents,
|
||||
"generationConfig": generation_config,
|
||||
"safetySettings": safety_settings,
|
||||
"systemInstruction": {
|
||||
"parts": [{"text": SYSTEM_PROMPT_TEXT}]
|
||||
}
|
||||
});
|
||||
|
||||
let url = format!(
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/{}:generateContent?key={}",
|
||||
GEMINI_MODEL, api_key
|
||||
);
|
||||
|
||||
let response = client
|
||||
.post(&url)
|
||||
.json(&request_body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("HTTP request failed: {}", e))?;
|
||||
|
||||
let status = response.status();
|
||||
let body: Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse response: {}", e))?;
|
||||
|
||||
if !status.is_success() {
|
||||
let error_msg = body["error"]["message"]
|
||||
.as_str()
|
||||
.unwrap_or("Unknown API error");
|
||||
return Err(format!("Gemini API error ({}): {}", status, error_msg));
|
||||
}
|
||||
|
||||
let parts = body["candidates"][0]["content"]["parts"]
|
||||
.as_array()
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"No parts in response. Full response: {}",
|
||||
serde_json::to_string_pretty(&body).unwrap_or_default()
|
||||
)
|
||||
})?;
|
||||
|
||||
let result_parts: Vec<MessagePart> = parts
|
||||
.iter()
|
||||
.filter_map(|part| {
|
||||
if part["thought"].as_bool() == Some(true) {
|
||||
part["text"].as_str().map(|text| MessagePart {
|
||||
part_type: "thought".to_string(),
|
||||
text: Some(text.to_string()),
|
||||
image_data: None,
|
||||
mime_type: None,
|
||||
thought_signature: None,
|
||||
})
|
||||
} else if let Some(text) = part["text"].as_str() {
|
||||
Some(MessagePart {
|
||||
part_type: "text".to_string(),
|
||||
text: Some(text.to_string()),
|
||||
image_data: None,
|
||||
mime_type: None,
|
||||
thought_signature: None,
|
||||
})
|
||||
} else if let Some(inline_data) = part["inlineData"].as_object() {
|
||||
let mime = inline_data["mimeType"]
|
||||
.as_str()
|
||||
.unwrap_or("image/png")
|
||||
.to_string();
|
||||
let data = inline_data["data"].as_str().unwrap_or("").to_string();
|
||||
let thought_signature = part["thoughtSignature"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string());
|
||||
Some(MessagePart {
|
||||
part_type: "image".to_string(),
|
||||
text: None,
|
||||
image_data: Some(data),
|
||||
mime_type: Some(mime),
|
||||
thought_signature,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let usage = &body["usageMetadata"];
|
||||
let prompt_tokens = usage["promptTokenCount"].as_u64().unwrap_or(0);
|
||||
let candidates_tokens = usage["candidatesTokenCount"].as_u64().unwrap_or(0);
|
||||
let image_part_count = result_parts.iter().filter(|p| p.part_type == "image").count() as u64;
|
||||
|
||||
// Image output tokens (4K = 2000 tokens each) billed at $120/1M tokens
|
||||
let image_output_tokens = image_part_count * 2_000_u64;
|
||||
// Remaining candidates tokens are text/thinking, billed at $12/1M tokens
|
||||
let text_output_tokens = candidates_tokens.saturating_sub(image_output_tokens);
|
||||
|
||||
let input_cost = prompt_tokens as f64 * (2.00 / 1_000_000.0);
|
||||
let output_text_cost = text_output_tokens as f64 * (12.00 / 1_000_000.0);
|
||||
let output_image_cost = image_output_tokens as f64 * (120.00 / 1_000_000.0);
|
||||
let total_cost = input_cost + output_text_cost + output_image_cost;
|
||||
|
||||
Ok((result_parts, total_cost))
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
mod gemini;
|
||||
mod storage;
|
||||
|
||||
use gemini::{call_gemini, read_reference_image_base64};
|
||||
use serde::Serialize;
|
||||
use storage::{
|
||||
delete_thread_from_disk, load_config_from_disk, load_threads_from_disk, save_config_to_disk,
|
||||
save_thread_to_disk, Config, MessagePart, Thread, ThreadMessage,
|
||||
};
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SendMessageResult {
|
||||
parts: Vec<MessagePart>,
|
||||
#[serde(rename = "costUsd")]
|
||||
cost_usd: f64,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn load_threads() -> Result<Vec<Thread>, String> {
|
||||
Ok(load_threads_from_disk())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn save_thread(thread: Thread) -> Result<(), String> {
|
||||
save_thread_to_disk(thread)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn delete_thread(thread_id: String) -> Result<(), String> {
|
||||
delete_thread_from_disk(&thread_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn read_reference_image() -> String {
|
||||
read_reference_image_base64()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn load_config() -> Result<Config, String> {
|
||||
Ok(load_config_from_disk())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn save_config(config: Config) -> Result<(), String> {
|
||||
save_config_to_disk(config)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn send_message(
|
||||
api_key: String,
|
||||
mode: String,
|
||||
history: Vec<ThreadMessage>,
|
||||
user_text: Option<String>,
|
||||
user_image_base64: Option<String>,
|
||||
user_image_mime: Option<String>,
|
||||
) -> Result<SendMessageResult, String> {
|
||||
let (parts, cost_usd) =
|
||||
call_gemini(api_key, mode, history, user_text, user_image_base64, user_image_mime).await?;
|
||||
Ok(SendMessageResult { parts, cost_usd })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn open_url(url: String) -> Result<(), String> {
|
||||
open::that(&url).map_err(|e| format!("Failed to open URL: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn save_image(
|
||||
app: tauri::AppHandle,
|
||||
base64_data: String,
|
||||
mime_type: String,
|
||||
file_name: String,
|
||||
) -> Result<(), String> {
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
|
||||
let extension = if mime_type.contains("jpeg") || mime_type.contains("jpg") {
|
||||
"jpg"
|
||||
} else {
|
||||
"png"
|
||||
};
|
||||
|
||||
let path = app
|
||||
.dialog()
|
||||
.file()
|
||||
.add_filter("Image", &[extension])
|
||||
.set_file_name(&file_name)
|
||||
.blocking_save_file();
|
||||
|
||||
if let Some(file_path) = path {
|
||||
let bytes = BASE64
|
||||
.decode(&base64_data)
|
||||
.map_err(|e| format!("Failed to decode image: {}", e))?;
|
||||
let path_buf: std::path::PathBuf = file_path.into_path().map_err(|e| format!("Invalid path: {}", e))?;
|
||||
std::fs::write(&path_buf, bytes).map_err(|e| format!("Failed to save: {}", e))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
delete_thread,
|
||||
load_config,
|
||||
load_threads,
|
||||
open_url,
|
||||
read_reference_image,
|
||||
save_config,
|
||||
save_image,
|
||||
save_thread,
|
||||
send_message,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
tatsumi_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MessagePart {
|
||||
#[serde(rename = "type")]
|
||||
pub part_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub text: Option<String>,
|
||||
#[serde(rename = "imageData", skip_serializing_if = "Option::is_none")]
|
||||
pub image_data: Option<String>,
|
||||
#[serde(rename = "mimeType", skip_serializing_if = "Option::is_none")]
|
||||
pub mime_type: Option<String>,
|
||||
// Required by the Gemini API when sending model-generated images back in history
|
||||
#[serde(rename = "thoughtSignature", skip_serializing_if = "Option::is_none")]
|
||||
pub thought_signature: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ThreadMessage {
|
||||
pub role: String,
|
||||
pub parts: Vec<MessagePart>,
|
||||
#[serde(rename = "costUsd", skip_serializing_if = "Option::is_none")]
|
||||
pub cost_usd: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Thread {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub mode: String,
|
||||
pub messages: Vec<ThreadMessage>,
|
||||
#[serde(rename = "createdAt")]
|
||||
pub created_at: i64,
|
||||
#[serde(rename = "updatedAt")]
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct Config {
|
||||
#[serde(rename = "apiKey")]
|
||||
pub api_key: String,
|
||||
}
|
||||
|
||||
fn get_app_data_dir() -> PathBuf {
|
||||
let data_dir = dirs::data_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("com.naomi.tatsumi");
|
||||
|
||||
fs::create_dir_all(&data_dir).unwrap_or(());
|
||||
data_dir
|
||||
}
|
||||
|
||||
fn get_threads_path() -> PathBuf {
|
||||
get_app_data_dir().join("threads.json")
|
||||
}
|
||||
|
||||
pub fn get_config_path() -> PathBuf {
|
||||
get_app_data_dir().join("config.json")
|
||||
}
|
||||
|
||||
pub fn load_config_from_disk() -> Config {
|
||||
let path = get_config_path();
|
||||
if !path.exists() {
|
||||
return Config::default();
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&path).unwrap_or_else(|_| "{}".to_string());
|
||||
serde_json::from_str(&content).unwrap_or_else(|_| Config::default())
|
||||
}
|
||||
|
||||
pub fn save_config_to_disk(config: Config) -> Result<(), String> {
|
||||
let path = get_config_path();
|
||||
let content = serde_json::to_string(&config).map_err(|e| e.to_string())?;
|
||||
fs::write(&path, content).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn load_threads_from_disk() -> Vec<Thread> {
|
||||
let path = get_threads_path();
|
||||
if !path.exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&path).unwrap_or_else(|_| "[]".to_string());
|
||||
serde_json::from_str(&content).unwrap_or_else(|_| Vec::new())
|
||||
}
|
||||
|
||||
pub fn save_thread_to_disk(thread: Thread) -> Result<(), String> {
|
||||
let path = get_threads_path();
|
||||
let mut threads = load_threads_from_disk();
|
||||
|
||||
if let Some(existing) = threads.iter_mut().find(|t| t.id == thread.id) {
|
||||
*existing = thread;
|
||||
} else {
|
||||
threads.push(thread);
|
||||
}
|
||||
|
||||
let content = serde_json::to_string(&threads).map_err(|e| e.to_string())?;
|
||||
fs::write(&path, content).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn delete_thread_from_disk(thread_id: &str) -> Result<(), String> {
|
||||
let path = get_threads_path();
|
||||
let mut threads = load_threads_from_disk();
|
||||
threads.retain(|t| t.id != thread_id);
|
||||
|
||||
let content = serde_json::to_string(&threads).map_err(|e| e.to_string())?;
|
||||
fs::write(&path, content).map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "tatsumi",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.naomi.tatsumi",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "pnpm build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Tatsumi",
|
||||
"width": 1400,
|
||||
"height": 900,
|
||||
"minWidth": 900,
|
||||
"minHeight": 600,
|
||||
"center": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||