feat: initial Tatsumi release
CI / Lint & Check (push) Failing after 12s
CI / Build Windows (push) Has been skipped
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m5s

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.
This commit is contained in:
2026-04-09 20:16:54 -07:00
committed by Naomi Carrigan
parent cf544434d3
commit f2c4fb34b7
84 changed files with 14229 additions and 10 deletions
+5609
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -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"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
+25
View File
@@ -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/**" }]
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 MiB

+262
View File
@@ -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))
}
+119
View File
@@ -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");
}
+6
View File
@@ -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()
}
+110
View File
@@ -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())
}
+38
View File
@@ -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"
]
}
}