Compare commits

..

6 Commits

Author SHA1 Message Date
hikari c705b1385a feat: update reference sheet and refine character design prompt
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m13s
CI / Lint & Check (pull_request) Successful in 12m46s
CI / Build Windows (pull_request) Successful in 26m0s
Updates the reference image to the latest version. Removes the barefoot
instruction from the character prompt and clarifies that vampire fangs
are only visible when the mouth is open.
2026-04-28 13:32:17 -07:00
naomi 5c65eb9ae5 release: v1.1.0
CI / Lint & Check (push) Successful in 13m32s
CI / Build Windows (push) Successful in 25m52s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 2m41s
2026-04-13 21:00:16 -07:00
hikari 4776f00e51 feat: generate adjective-noun combo names for threads (#19)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m10s
CI / Lint & Check (push) Successful in 12m45s
CI / Build Windows (push) Has been cancelled
Replaces the boring timestamp/prompt-truncation thread naming with randomly generated adjective-noun combos (e.g. *Crimson Reverie*, *Neon Phantom*, *Gilded Silence*).

50 adjectives × 50 nouns = 2,500 unique possible thread names, applied consistently across all three modes.

 This issue was created with help from Hikari~ 🌸

Reviewed-on: #19
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-04-13 20:45:15 -07:00
hikari 9f45ee329d feat: add user-selectable aspect ratio and resolution per thread (#18)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m14s
CI / Lint & Check (push) Successful in 12m50s
CI / Build Windows (push) Successful in 28m36s
## Summary

- Adds a two-step thread creation modal — step 1 picks the mode, step 2 configures generation options
- Art mode now supports user-selectable aspect ratio (1:1, 4:3, 3:4, 16:9, 9:16, 21:9)
- All three modes (Art, Avatar, Replace) now support user-selectable resolution (1K, 2K, 4K)
- Mode label in the input area reflects the chosen settings (e.g. `🩷 Art Mode (16:9) · 4K`)
- Backend cost calculation now scales with resolution
- Regenerated `icon.ico` with clean BMP-only entries to fix Windows local builds

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #18
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-04-13 18:24:46 -07:00
hikari 5bfd25e60d fix(ci): pin LLVM 18 for Windows cross-compilation (#17)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m13s
CI / Lint & Check (push) Successful in 13m23s
CI / Build Windows (push) Successful in 36m51s
## Summary

The Windows build job has been failing on every PR since the initial release. The root cause is that the default `clang`/`lld`/`llvm` apt packages resolve to an older version of `llvm-rc` that cannot handle PNG-compressed entries in `.ico` files — and all six images in `icon.ico` use PNG compression.

Pinning to `clang-18`, `lld-18`, and `llvm-18` (available in Ubuntu 24.04's default repos) and registering them via `update-alternatives` ensures `llvm-rc`, `clang-cl`, `lld-link`, and friends all resolve to a version that handles the icon resource correctly.

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #17
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-04-13 16:21:42 -07:00
hikari 662d6119fa feat: add image input to art/avatar modes and notes to replace mode (#16)
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m8s
CI / Lint & Check (push) Successful in 12m40s
CI / Build Windows (push) Failing after 25m14s
## Summary

- Art and Avatar modes now support optional image uploads (paste or file picker), sent to Gemini alongside the text prompt
- Initial Replace mode gains an optional textarea for notes to include with the uploaded image
- Backend updated to send user images for all modes, appending replace instructions only when in replace mode
- Send validation updated to allow image-only messages in art and avatar modes

 This PR was created with help from Hikari~ 🌸

Reviewed-on: #16
Co-authored-by: Hikari <hikari@nhcarrigan.com>
Co-committed-by: Hikari <hikari@nhcarrigan.com>
2026-04-13 10:18:35 -07:00
15 changed files with 655 additions and 164 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "tatsumi",
"private": true,
"version": "1.0.0",
"version": "1.1.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -20,7 +20,7 @@
"devDependencies": {
"@nhcarrigan/eslint-config": "5.2.0",
"@nhcarrigan/typescript-config": "4.0.0",
"@tauri-apps/cli": "2.10.1",
"@tauri-apps/cli": "2.5.0",
"@types/react": "19.1.2",
"@types/react-dom": "19.1.2",
"autoprefixer": "10.4.21",
+49 -49
View File
@@ -31,8 +31,8 @@ importers:
specifier: 4.0.0
version: 4.0.0(typescript@5.8.3)
'@tauri-apps/cli':
specifier: 2.10.1
version: 2.10.1
specifier: 2.5.0
version: 2.5.0
'@types/react':
specifier: 19.1.2
version: 19.1.2
@@ -578,74 +578,74 @@ packages:
'@tauri-apps/api@2.5.0':
resolution: {integrity: sha512-Ldux4ip+HGAcPUmuLT8EIkk6yafl5vK0P0c0byzAKzxJh7vxelVtdPONjfgTm96PbN24yjZNESY8CKo8qniluA==}
'@tauri-apps/cli-darwin-arm64@2.10.1':
resolution: {integrity: sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==}
'@tauri-apps/cli-darwin-arm64@2.5.0':
resolution: {integrity: sha512-VuVAeTFq86dfpoBDNYAdtQVLbP0+2EKCHIIhkaxjeoPARR0sLpFHz2zs0PcFU76e+KAaxtEtAJAXGNUc8E1PzQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@tauri-apps/cli-darwin-x64@2.10.1':
resolution: {integrity: sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==}
'@tauri-apps/cli-darwin-x64@2.5.0':
resolution: {integrity: sha512-hUF01sC06cZVa8+I0/VtsHOk9BbO75rd+YdtHJ48xTdcYaQ5QIwL4yZz9OR1AKBTaUYhBam8UX9Pvd5V2/4Dpw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@tauri-apps/cli-linux-arm-gnueabihf@2.10.1':
resolution: {integrity: sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==}
'@tauri-apps/cli-linux-arm-gnueabihf@2.5.0':
resolution: {integrity: sha512-LQKqttsK252LlqYyX8R02MinUsfFcy3+NZiJwHFgi5Y3+ZUIAED9cSxJkyNtuY5KMnR4RlpgWyLv4P6akN1xhg==}
engines: {node: '>= 10'}
cpu: [arm]
os: [linux]
'@tauri-apps/cli-linux-arm64-gnu@2.10.1':
resolution: {integrity: sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==}
'@tauri-apps/cli-linux-arm64-gnu@2.5.0':
resolution: {integrity: sha512-mTQufsPcpdHg5RW0zypazMo4L55EfeE5snTzrPqbLX4yCK2qalN7+rnP8O8GT06xhp6ElSP/Ku1M2MR297SByQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@tauri-apps/cli-linux-arm64-musl@2.10.1':
resolution: {integrity: sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==}
'@tauri-apps/cli-linux-arm64-musl@2.5.0':
resolution: {integrity: sha512-rQO1HhRUQqyEaal5dUVOQruTRda/TD36s9kv1hTxZiFuSq3558lsTjAcUEnMAtBcBkps20sbyTJNMT0AwYIk8Q==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@tauri-apps/cli-linux-riscv64-gnu@2.10.1':
resolution: {integrity: sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==}
'@tauri-apps/cli-linux-riscv64-gnu@2.5.0':
resolution: {integrity: sha512-7oS18FN46yDxyw1zX/AxhLAd7T3GrLj3Ai6s8hZKd9qFVzrAn36ESL7d3G05s8wEtsJf26qjXnVF4qleS3dYsA==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
'@tauri-apps/cli-linux-x64-gnu@2.10.1':
resolution: {integrity: sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==}
'@tauri-apps/cli-linux-x64-gnu@2.5.0':
resolution: {integrity: sha512-SG5sFNL7VMmDBdIg3nO3EzNRT306HsiEQ0N90ILe3ZABYAVoPDO/ttpCO37ApLInTzrq/DLN+gOlC/mgZvLw1w==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@tauri-apps/cli-linux-x64-musl@2.10.1':
resolution: {integrity: sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==}
'@tauri-apps/cli-linux-x64-musl@2.5.0':
resolution: {integrity: sha512-QXDM8zp/6v05PNWju5ELsVwF0VH1n6b5pk2E6W/jFbbiwz80Vs1lACl9pv5kEHkrxBj+aWU/03JzGuIj2g3SkQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@tauri-apps/cli-win32-arm64-msvc@2.10.1':
resolution: {integrity: sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==}
'@tauri-apps/cli-win32-arm64-msvc@2.5.0':
resolution: {integrity: sha512-pFSHFK6b+o9y4Un8w0gGLwVyFTZaC3P0kQ7umRt/BLDkzD5RnQ4vBM7CF8BCU5nkwmEBUCZd7Wt3TWZxe41o6Q==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@tauri-apps/cli-win32-ia32-msvc@2.10.1':
resolution: {integrity: sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==}
'@tauri-apps/cli-win32-ia32-msvc@2.5.0':
resolution: {integrity: sha512-EArv1IaRlogdLAQyGlKmEqZqm5RfHCUMhJoedWu7GtdbOMUfSAz6FMX2boE1PtEmNO4An+g188flLeVErrxEKg==}
engines: {node: '>= 10'}
cpu: [ia32]
os: [win32]
'@tauri-apps/cli-win32-x64-msvc@2.10.1':
resolution: {integrity: sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==}
'@tauri-apps/cli-win32-x64-msvc@2.5.0':
resolution: {integrity: sha512-lj43EFYbnAta8pd9JnUq87o+xRUR0odz+4rixBtTUwUgdRdwQ2V9CzFtsMu6FQKpFQ6mujRK6P1IEwhL6ADRsQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@tauri-apps/cli@2.10.1':
resolution: {integrity: sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==}
'@tauri-apps/cli@2.5.0':
resolution: {integrity: sha512-rAtHqG0Gh/IWLjN2zTf3nZqYqbo81oMbqop56rGTjrlWk9pTTAjkqOjSL9XQLIMZ3RbeVjveCqqCA0s8RnLdMg==}
engines: {node: '>= 10'}
hasBin: true
@@ -2849,52 +2849,52 @@ snapshots:
'@tauri-apps/api@2.5.0': {}
'@tauri-apps/cli-darwin-arm64@2.10.1':
'@tauri-apps/cli-darwin-arm64@2.5.0':
optional: true
'@tauri-apps/cli-darwin-x64@2.10.1':
'@tauri-apps/cli-darwin-x64@2.5.0':
optional: true
'@tauri-apps/cli-linux-arm-gnueabihf@2.10.1':
'@tauri-apps/cli-linux-arm-gnueabihf@2.5.0':
optional: true
'@tauri-apps/cli-linux-arm64-gnu@2.10.1':
'@tauri-apps/cli-linux-arm64-gnu@2.5.0':
optional: true
'@tauri-apps/cli-linux-arm64-musl@2.10.1':
'@tauri-apps/cli-linux-arm64-musl@2.5.0':
optional: true
'@tauri-apps/cli-linux-riscv64-gnu@2.10.1':
'@tauri-apps/cli-linux-riscv64-gnu@2.5.0':
optional: true
'@tauri-apps/cli-linux-x64-gnu@2.10.1':
'@tauri-apps/cli-linux-x64-gnu@2.5.0':
optional: true
'@tauri-apps/cli-linux-x64-musl@2.10.1':
'@tauri-apps/cli-linux-x64-musl@2.5.0':
optional: true
'@tauri-apps/cli-win32-arm64-msvc@2.10.1':
'@tauri-apps/cli-win32-arm64-msvc@2.5.0':
optional: true
'@tauri-apps/cli-win32-ia32-msvc@2.10.1':
'@tauri-apps/cli-win32-ia32-msvc@2.5.0':
optional: true
'@tauri-apps/cli-win32-x64-msvc@2.10.1':
'@tauri-apps/cli-win32-x64-msvc@2.5.0':
optional: true
'@tauri-apps/cli@2.10.1':
'@tauri-apps/cli@2.5.0':
optionalDependencies:
'@tauri-apps/cli-darwin-arm64': 2.10.1
'@tauri-apps/cli-darwin-x64': 2.10.1
'@tauri-apps/cli-linux-arm-gnueabihf': 2.10.1
'@tauri-apps/cli-linux-arm64-gnu': 2.10.1
'@tauri-apps/cli-linux-arm64-musl': 2.10.1
'@tauri-apps/cli-linux-riscv64-gnu': 2.10.1
'@tauri-apps/cli-linux-x64-gnu': 2.10.1
'@tauri-apps/cli-linux-x64-musl': 2.10.1
'@tauri-apps/cli-win32-arm64-msvc': 2.10.1
'@tauri-apps/cli-win32-ia32-msvc': 2.10.1
'@tauri-apps/cli-win32-x64-msvc': 2.10.1
'@tauri-apps/cli-darwin-arm64': 2.5.0
'@tauri-apps/cli-darwin-x64': 2.5.0
'@tauri-apps/cli-linux-arm-gnueabihf': 2.5.0
'@tauri-apps/cli-linux-arm64-gnu': 2.5.0
'@tauri-apps/cli-linux-arm64-musl': 2.5.0
'@tauri-apps/cli-linux-riscv64-gnu': 2.5.0
'@tauri-apps/cli-linux-x64-gnu': 2.5.0
'@tauri-apps/cli-linux-x64-musl': 2.5.0
'@tauri-apps/cli-win32-arm64-msvc': 2.5.0
'@tauri-apps/cli-win32-ia32-msvc': 2.5.0
'@tauri-apps/cli-win32-x64-msvc': 2.5.0
'@tauri-apps/plugin-dialog@2.3.0':
dependencies:
+1 -1
View File
@@ -3716,7 +3716,7 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]]
name = "tatsumi"
version = "1.0.0"
version = "1.1.0"
dependencies = [
"base64 0.22.1",
"dirs 5.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "tatsumi"
version = "1.0.0"
version = "1.1.0"
description = "Tatsumi - AI art generation using Google Gemini"
authors = ["Naomi Carrigan"]
edition = "2021"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

After

Width:  |  Height:  |  Size: 298 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 MiB

After

Width:  |  Height:  |  Size: 17 MiB

+58 -23
View File
@@ -11,11 +11,11 @@ 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
- Vampire fangs (only visible when mouth is open)
- 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
- Full body visible in frame
Composition (always required):
- Single character only
@@ -46,12 +46,19 @@ fn build_safety_settings() -> Value {
])
}
fn build_generation_config(mode: &str) -> Value {
fn build_generation_config(
mode: &str,
aspect_ratio: Option<&str>,
image_size: &str,
) -> Value {
let image_config = match mode {
"avatar" => json!({ "aspectRatio": "1:1", "imageSize": "4K" }),
"art" => json!({ "aspectRatio": "16:9", "imageSize": "4K" }),
"avatar" => json!({ "aspectRatio": "1:1", "imageSize": image_size }),
"art" => {
let ratio = aspect_ratio.unwrap_or("16:9");
json!({ "aspectRatio": ratio, "imageSize": image_size })
}
// replace mode: omit aspectRatio so the model infers it from the source image
_ => json!({ "imageSize": "4K" }),
_ => json!({ "imageSize": image_size }),
};
json!({
"imageConfig": image_config,
@@ -83,37 +90,56 @@ fn build_user_gemini_parts(
user_image_base64: &Option<String>,
user_image_mime: &Option<String>,
) -> Vec<Value> {
if mode == "replace" && user_image_base64.is_some() {
if let Some(image_data) = user_image_base64.as_deref() {
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()
let final_text = if mode == "replace" {
if base_text.is_empty() {
REPLACE_MODE_APPEND.to_string()
} else {
format!("{}\n{}", base_text, REPLACE_MODE_APPEND)
}
} else {
format!("{}\n{}", base_text, REPLACE_MODE_APPEND)
base_text.to_string()
};
vec![
json!({"inlineData": {"mimeType": mime, "data": data}}),
json!({"text": final_text}),
]
let mut parts = vec![json!({"inlineData": {"mimeType": mime, "data": image_data}})];
if !final_text.is_empty() {
parts.push(json!({"text": final_text}));
}
parts
} else {
// Art/avatar mode, or replace mode follow-up correction (text only)
// No image: text-only message
let text = user_text.as_deref().unwrap_or("");
vec![json!({"text": text})]
}
}
pub struct GeminiCallParams {
pub mode: String,
pub aspect_ratio: Option<String>,
pub image_size: String,
pub user_text: Option<String>,
pub user_image_base64: Option<String>,
pub user_image_mime: Option<String>,
}
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>,
params: GeminiCallParams,
) -> Result<(Vec<MessagePart>, f64), String> {
let client = reqwest::Client::new();
let GeminiCallParams {
mode,
aspect_ratio,
image_size,
user_text,
user_image_base64,
user_image_mime,
} = params;
let is_first_message = history.is_empty();
let mut contents: Vec<Value> = history
@@ -156,7 +182,11 @@ pub async fn call_gemini(
contents.push(json!({"role": "user", "parts": user_parts}));
let generation_config = build_generation_config(mode.as_str());
let generation_config = build_generation_config(
mode.as_str(),
aspect_ratio.as_deref(),
image_size.as_str(),
);
let safety_settings = build_safety_settings();
let request_body = json!({
@@ -248,8 +278,13 @@ pub async fn call_gemini(
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;
// Image output tokens per image vary by resolution, billed at $120/1M tokens
let tokens_per_image: u64 = match image_size.as_str() {
"1K" => 500,
"2K" => 1_000,
_ => 2_000, // 4K default
};
let image_output_tokens = image_part_count * tokens_per_image;
// Remaining candidates tokens are text/thinking, billed at $12/1M tokens
let text_output_tokens = candidates_tokens.saturating_sub(image_output_tokens);
+17 -3
View File
@@ -1,7 +1,7 @@
mod gemini;
mod storage;
use gemini::{call_gemini, read_reference_image_base64};
use gemini::{call_gemini, read_reference_image_base64, GeminiCallParams};
use serde::Serialize;
use storage::{
delete_thread_from_disk, load_config_from_disk, load_threads_from_disk, save_config_to_disk,
@@ -46,16 +46,30 @@ async fn save_config(config: Config) -> Result<(), String> {
}
#[tauri::command]
#[allow(clippy::too_many_arguments)]
async fn send_message(
api_key: String,
mode: String,
aspect_ratio: Option<String>,
image_size: 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?;
let (parts, cost_usd) = call_gemini(
api_key,
history,
GeminiCallParams {
mode,
aspect_ratio,
image_size,
user_text,
user_image_base64,
user_image_mime,
},
)
.await?;
Ok(SendMessageResult { parts, cost_usd })
}
+8
View File
@@ -25,11 +25,19 @@ pub struct ThreadMessage {
pub cost_usd: Option<f64>,
}
fn default_image_size() -> String {
"4K".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Thread {
pub id: String,
pub name: String,
pub mode: String,
#[serde(rename = "aspectRatio", skip_serializing_if = "Option::is_none")]
pub aspect_ratio: Option<String>,
#[serde(rename = "imageSize", default = "default_image_size")]
pub image_size: String,
pub messages: Vec<ThreadMessage>,
#[serde(rename = "createdAt")]
pub created_at: i64,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "tatsumi",
"version": "1.0.0",
"version": "1.1.0",
"identifier": "com.naomi.tatsumi",
"build": {
"beforeDevCommand": "pnpm dev",
+129 -18
View File
@@ -16,17 +16,127 @@ import { SettingsModal } from "./components/settingsModal.tsx";
import { Sidebar } from "./components/sidebar.tsx";
import { ThreadView } from "./components/threadView.tsx";
import { WelcomeScreen } from "./components/welcomeScreen.tsx";
import type { Config, Mode, PendingInput, Thread } from "./types/index.ts";
import type {
AspectRatio,
Config,
ImageSize,
Mode,
PendingInput,
Thread,
} from "./types/index.ts";
const generateThreadName = (mode: Mode, text?: string): string => {
if (
mode === "replace"
|| text === undefined
|| text.trim().length === 0
) {
return `Replace - ${new Date().toLocaleString()}`;
}
return text.trim().slice(0, 40);
const adjectives = [
"Amber",
"Ancient",
"Arctic",
"Azure",
"Blazing",
"Celestial",
"Cobalt",
"Crimson",
"Crystal",
"Dark",
"Distant",
"Ember",
"Emerald",
"Eternal",
"Ethereal",
"Fleeting",
"Frosted",
"Gilded",
"Golden",
"Hidden",
"Hollow",
"Indigo",
"Ivory",
"Jade",
"Lunar",
"Midnight",
"Mystic",
"Neon",
"Obsidian",
"Onyx",
"Opal",
"Pale",
"Pearl",
"Radiant",
"Rusted",
"Sapphire",
"Scarlet",
"Shattered",
"Silent",
"Silver",
"Spectral",
"Twilight",
"Velvet",
"Verdant",
"Veiled",
"Violet",
"Wandering",
"Wild",
"Woven",
"Arcane",
];
const nouns = [
"Abyss",
"Archive",
"Aria",
"Bloom",
"Cascade",
"Chronicle",
"Cipher",
"Compass",
"Crown",
"Dawn",
"Dream",
"Echo",
"Elegy",
"Ember",
"Equinox",
"Flame",
"Fragment",
"Garden",
"Horizon",
"Labyrinth",
"Lantern",
"Mirage",
"Mist",
"Murmur",
"Nexus",
"Nocturne",
"Oracle",
"Phantom",
"Prism",
"Requiem",
"Reverie",
"Ruin",
"Shadow",
"Shard",
"Silence",
"Solstice",
"Sonata",
"Specter",
"Storm",
"Tempest",
"Throne",
"Tide",
"Veil",
"Vortex",
"Whisper",
"Zenith",
"Sigil",
"Glyph",
"Dusk",
"Omen",
];
const generateThreadName = (): string => {
const adjectiveIndex = Math.floor(Math.random() * adjectives.length);
const nounIndex = Math.floor(Math.random() * nouns.length);
const adjective = adjectives[adjectiveIndex] ?? "Arcane";
const noun = nouns[nounIndex] ?? "Reverie";
return `${adjective} ${noun}`;
};
const generateId = (): string => {
@@ -67,13 +177,7 @@ const resolveUpdatedThread = (updatedThread: Thread): Thread => {
return updatedThread;
}
const firstTextPart = firstMessage.parts.find((part) => {
return part.type === "text";
});
const name = generateThreadName(
updatedThread.mode,
firstTextPart?.text,
);
const name = generateThreadName();
return { ...updatedThread, name };
};
@@ -120,11 +224,18 @@ const app = (): JSX.Element => {
});
const handleNewThread = useCallback(
(mode: Mode): void => {
(
mode: Mode,
aspectRatio: AspectRatio | undefined,
imageSize: ImageSize,
): void => {
const now = Date.now();
const createdThread: Thread = {
/* eslint-disable-next-line stylistic/no-extra-parens -- Required for conditional spread with exactOptionalPropertyTypes */
...(aspectRatio !== undefined && { aspectRatio }),
createdAt: now,
id: generateId(),
imageSize: imageSize,
messages: [],
mode: mode,
name: `New ${mode} thread`,
+68 -40
View File
@@ -19,7 +19,12 @@ import {
type JSX,
type KeyboardEvent,
} from "react";
import type { Mode, PendingInput } from "../types/index.ts";
import type {
AspectRatio,
ImageSize,
Mode,
PendingInput,
} from "../types/index.ts";
const dropZoneBaseClass = [
"border-2 border-dashed rounded-xl p-8",
@@ -113,14 +118,19 @@ const readClipboardImage = async(
onFileRead(new File([ blob ], "clipboard.png", { type: imageType }));
};
const modeLabelText = (mode: Mode): string => {
const modeLabelText = (
mode: Mode,
aspectRatio: AspectRatio | undefined,
imageSize: ImageSize,
): string => {
if (mode === "avatar") {
return "🟣 Avatar Mode (1:1)";
return `🟣 Avatar Mode · ${imageSize}`;
}
if (mode === "art") {
return "🩷 Art Mode (16:9)";
const ratio = aspectRatio ?? "16:9";
return `🩷 Art Mode (${ratio}) · ${imageSize}`;
}
return "🔵 Replace Mode";
return `🔵 Replace Mode · ${imageSize}`;
};
type OnSendCallback = (
@@ -130,7 +140,9 @@ type OnSendCallback = (
)=> void;
interface InputAreaProperties {
readonly aspectRatio?: AspectRatio;
readonly hasMessages: boolean;
readonly imageSize: ImageSize;
readonly initialImageBase64?: string;
readonly initialImageMime?: string;
readonly initialImagePreview?: string;
@@ -144,7 +156,9 @@ interface InputAreaProperties {
/**
* Renders the input area for composing and sending messages.
* @param props - The component props.
* @param props.aspectRatio - The thread's aspect ratio setting (Art mode only).
* @param props.hasMessages - Whether the thread already has messages (affects replace mode UI).
* @param props.imageSize - The thread's resolution setting.
* @param props.initialImageBase64 - Initial base64 image data to pre-populate.
* @param props.initialImageMime - Initial image MIME type to pre-populate.
* @param props.initialImagePreview - Initial image preview URL to pre-populate.
@@ -156,7 +170,9 @@ interface InputAreaProperties {
* @returns The JSX element.
*/
const inputArea = ({
aspectRatio,
hasMessages,
imageSize,
initialImageBase64,
initialImageMime,
initialImagePreview,
@@ -256,7 +272,8 @@ const inputArea = ({
if (isInitialReplace && imageBase64 === undefined) {
return;
}
if (!isInitialReplace && text.trim().length === 0) {
const hasContent = text.trim().length > 0 || imageBase64 !== undefined;
if (!isInitialReplace && !hasContent) {
return;
}
@@ -332,11 +349,14 @@ const inputArea = ({
: dropZoneInactiveClass,
].join(" ");
const hasNoContent = text.trim().length === 0 && imageBase64 === undefined;
const isSendDisabled = isLoading || hasNoContent;
return (
<div className="border-t border-purple-900/30 p-4 bg-[#0f0a1a]">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-gray-500 uppercase tracking-wider">
{modeLabelText(mode)}
{modeLabelText(mode, aspectRatio, imageSize)}
</span>
</div>
@@ -397,6 +417,15 @@ const inputArea = ({
type="file"
/>
<textarea
className={textareaClass}
disabled={isLoading}
onChange={handleTextChange}
placeholder="Add notes to include with the image (optional)..."
rows={2}
value={text}
/>
<button
className={replaceButtonClass}
disabled={isLoading || imageBase64 === undefined}
@@ -414,41 +443,40 @@ const inputArea = ({
</div>
: <div className="flex flex-col gap-3">
{mode === "replace"
? <div className="flex flex-col gap-2">
{imagePreview === undefined
? <button
className={pasteButtonClass}
onClick={handlePasteButtonClick}
<div className="flex flex-col gap-2">
{imagePreview === undefined
? <button
className={pasteButtonClass}
onClick={handlePasteButtonClick}
type="button"
>
{mode === "replace"
? "📋 Paste replacement image (optional)"
: "📋 Paste image (optional)"}
</button>
: <div className="relative inline-block">
<img
alt="Upload preview"
className="max-h-32 rounded-lg border border-purple-700/40"
src={imagePreview}
/>
<button
className={clearButtonClass}
onClick={clearImage}
type="button"
>
{"📋 Paste replacement image (optional)"}
{"×"}
</button>
: <div className="relative inline-block">
<img
alt="Upload preview"
className="max-h-32 rounded-lg border border-purple-700/40"
src={imagePreview}
/>
<button
className={clearButtonClass}
onClick={clearImage}
type="button"
>
{"×"}
</button>
</div>}
<input
accept="image/*"
className="hidden"
onChange={handleFileChange}
ref={fileInputReference}
type="file"
/>
</div>
: null}
</div>}
<input
accept="image/*"
className="hidden"
onChange={handleFileChange}
ref={fileInputReference}
type="file"
/>
</div>
<div className="flex gap-3 items-end">
<textarea
@@ -464,7 +492,7 @@ const inputArea = ({
/>
<button
className={sendButtonClass}
disabled={isLoading || text.trim().length === 0}
disabled={isSendDisabled}
onClick={handleSend}
type="button"
>
+267 -10
View File
@@ -1,12 +1,18 @@
/**
* @file Modal for selecting a new thread generation mode.
* @file Modal for selecting a new thread generation mode and options.
* @copyright Naomi Carrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Component JSX inherently requires many lines */
import { useCallback, type JSX, type MouseEvent } from "react";
import type { Mode } from "../types/index.ts";
/* eslint-disable max-lines -- Two-step modal requires many option definitions */
import {
useCallback,
useState,
type JSX,
type MouseEvent,
} from "react";
import type { AspectRatio, ImageSize, Mode } from "../types/index.ts";
interface ModeOption {
colour: string;
@@ -16,14 +22,25 @@ interface ModeOption {
mode: Mode;
}
interface AspectRatioOption {
label: string;
value: AspectRatio;
}
interface ImageSizeOption {
description: string;
label: string;
value: ImageSize;
}
const avatarDescription = [
"Generate a square 1:1 portrait.",
"Perfect for profile pictures and avatars.",
].join(" ");
const artDescription = [
"Generate wide 16:9 landscape artwork.",
"Great for wallpapers and banners.",
"Generate landscape or portrait artwork.",
"Choose your aspect ratio and resolution.",
].join(" ");
const replaceDescription = [
@@ -55,6 +72,21 @@ const modeOptionList: Array<ModeOption> = [
},
];
const aspectRatioOptions: Array<AspectRatioOption> = [
{ label: "1:1 Square", value: "1:1" },
{ label: "4:3 Standard", value: "4:3" },
{ label: "3:4 Portrait", value: "3:4" },
{ label: "16:9 Widescreen", value: "16:9" },
{ label: "9:16 Wallpaper", value: "9:16" },
{ label: "21:9 Ultrawide", value: "21:9" },
];
const imageSizeOptions: Array<ImageSizeOption> = [
{ description: "Faster, cheaper", label: "1K", value: "1K" },
{ description: "Balanced", label: "2K", value: "2K" },
{ description: "Best quality", label: "4K", value: "4K" },
];
const colourMap: Record<
string,
{ badge: string; button: string; hover: string }
@@ -82,6 +114,20 @@ const isMode = (value: string): value is Mode => {
return validModesSet.has(value);
};
const validAspectRatios = new Set<string>([
"1:1", "3:4", "4:3", "9:16", "16:9", "21:9",
]);
const isAspectRatio = (value: string): value is AspectRatio => {
return validAspectRatios.has(value);
};
const validImageSizes = new Set<string>([ "1K", "2K", "4K" ]);
const isImageSize = (value: string): value is ImageSize => {
return validImageSizes.has(value);
};
const overlayClass = [
"fixed inset-0 bg-black/70 backdrop-blur-sm",
"flex items-center justify-center z-50",
@@ -93,32 +139,243 @@ const panelClass = [
"shadow-2xl shadow-purple-900/30",
].join(" ");
const pillBaseClass = [
"px-4 py-2 rounded-xl border text-sm font-medium",
"transition-all duration-200 cursor-pointer",
].join(" ");
const pillActiveClass = "border-purple-500 bg-purple-600/30 text-white";
const pillInactiveClass = [
"border-purple-900/40 bg-transparent text-gray-400",
"hover:border-purple-500/60 hover:text-gray-200",
].join(" ");
const startButtonClass = [
"w-full bg-gradient-to-r from-purple-600 to-pink-600",
"hover:from-purple-500 hover:to-pink-500",
"text-white font-semibold py-3 rounded-xl",
"transition-all duration-200 mt-2",
].join(" ");
const sizeButtonActiveClass = "border-purple-500 bg-purple-600/30 text-white";
const sizeButtonInactiveClass = [
"border-purple-900/40 bg-transparent text-gray-400",
"hover:border-purple-500/60 hover:text-gray-200",
].join(" ");
const backButtonClass = [
"text-gray-400 hover:text-white",
"transition-colors text-xl",
].join(" ");
const sectionLabelClass = [
"text-sm text-gray-400",
"uppercase tracking-wider mb-3",
].join(" ");
interface NewThreadModalProperties {
readonly onClose: ()=> void;
readonly onSelect: (mode: Mode)=> void;
readonly onSelect: (
mode: Mode,
aspectRatio: AspectRatio | undefined,
imageSize: ImageSize,
)=> void;
}
/**
* Renders the new thread modal for selecting a generation mode.
* Renders the new thread modal for selecting a generation mode and options.
* @param props - The component props.
* @param props.onClose - Callback to close the modal.
* @param props.onSelect - Callback when a mode is selected.
* @param props.onSelect - Callback when a mode and options are confirmed.
* @returns The JSX element.
*/
const threadModal = ({
onClose,
onSelect,
}: NewThreadModalProperties): JSX.Element => {
const [ step, setStep ] = useState<"mode" | "options">("mode");
const [ selectedMode, setSelectedMode ] = useState<Mode | undefined>(
undefined,
);
const [ aspectRatio, setAspectRatio ] = useState<AspectRatio>("16:9");
const [ imageSize, setImageSize ] = useState<ImageSize>("4K");
const handleModeSelect = useCallback(
(event: MouseEvent<HTMLButtonElement>): void => {
const rawMode = event.currentTarget.dataset.mode;
if (rawMode !== undefined && isMode(rawMode)) {
onSelect(rawMode);
setSelectedMode(rawMode);
setStep("options");
}
},
[ onSelect ],
[],
);
const handleBack = useCallback((): void => {
setStep("mode");
}, []);
const handleStart = useCallback((): void => {
if (selectedMode === undefined) {
return;
}
const ratio = selectedMode === "art"
? aspectRatio
: undefined;
onSelect(selectedMode, ratio, imageSize);
}, [ selectedMode, aspectRatio, imageSize, onSelect ]);
const handleAspectRatioSelect = useCallback(
(event: MouseEvent<HTMLButtonElement>): void => {
const rawRatio = event.currentTarget.dataset.ratio;
if (rawRatio !== undefined && isAspectRatio(rawRatio)) {
setAspectRatio(rawRatio);
}
},
[],
);
const handleImageSizeSelect = useCallback(
(event: MouseEvent<HTMLButtonElement>): void => {
const rawSize = event.currentTarget.dataset.size;
if (rawSize !== undefined && isImageSize(rawSize)) {
setImageSize(rawSize);
}
},
[],
);
const selectedModeOption = modeOptionList.find((o) => {
return o.mode === selectedMode;
});
const selectedColours = selectedModeOption === undefined
? colourMap.purple
: colourMap[selectedModeOption.colour];
if (step === "options" && selectedModeOption !== undefined) {
return (
<div className={overlayClass}>
<div className={panelClass}>
<div className="flex items-center gap-3 mb-6">
<button
className={backButtonClass}
onClick={handleBack}
type="button"
>
{"←"}
</button>
<h2 className="text-2xl font-bold text-white flex-1">
{"Configure Thread"}
</h2>
<button
className="text-gray-400 hover:text-white transition-colors"
onClick={onClose}
type="button"
>
{"×"}
</button>
</div>
<div
className={[
"flex items-center gap-3 mb-6 p-4",
"rounded-xl bg-[#241836] border border-purple-900/30",
].join(" ")}
>
<span className="text-3xl">{selectedModeOption.icon}</span>
<div>
<div className="flex items-center gap-2">
<span className="text-white font-semibold">
{selectedModeOption.label}
</span>
<span
className={`text-xs px-2 py-0.5 rounded-full ${selectedColours?.badge ?? ""}`}
>
{selectedModeOption.mode}
</span>
</div>
<p className="text-gray-400 text-xs mt-0.5">
{selectedModeOption.description}
</p>
</div>
</div>
{selectedMode === "art"
? <div className="mb-6">
<p className={sectionLabelClass}>
{"Aspect Ratio"}
</p>
<div className="flex flex-wrap gap-2">
{aspectRatioOptions.map((option) => {
const pillClass = [
pillBaseClass,
aspectRatio === option.value
? pillActiveClass
: pillInactiveClass,
].join(" ");
return (
<button
className={pillClass}
data-ratio={option.value}
key={option.value}
onClick={handleAspectRatioSelect}
type="button"
>
{option.label}
</button>
);
})}
</div>
</div>
: null}
<div className="mb-6">
<p className={sectionLabelClass}>
{"Resolution"}
</p>
<div className="flex gap-3">
{imageSizeOptions.map((option) => {
const sizeClass = [
"flex-1 flex flex-col items-center py-3 px-2",
"rounded-xl border text-sm font-medium",
"transition-all duration-200 cursor-pointer",
imageSize === option.value
? sizeButtonActiveClass
: sizeButtonInactiveClass,
].join(" ");
return (
<button
className={sizeClass}
data-size={option.value}
key={option.value}
onClick={handleImageSizeSelect}
type="button"
>
<span className="text-base font-bold">{option.label}</span>
<span className="text-xs mt-0.5 opacity-70">
{option.description}
</span>
</button>
);
})}
</div>
</div>
<button
className={startButtonClass}
onClick={handleStart}
type="button"
>
{"Start Thread →"}
</button>
</div>
</div>
);
}
return (
<div className={overlayClass}>
<div className={panelClass}>
+34 -9
View File
@@ -68,19 +68,19 @@ const modeLabel = (mode: Mode): string => {
interface BuildUserPartsOptions {
readonly imageBase64?: string;
readonly imageMime?: string;
readonly mode: Mode;
readonly text: string;
}
const buildUserParts = ({
imageBase64,
imageMime,
mode,
text,
}: BuildUserPartsOptions): Array<MessagePart> => {
const userParts: Array<MessagePart> = [];
if (mode === "replace" && imageBase64 !== undefined) {
if (imageBase64 === undefined) {
userParts.push({ text: text, type: "text" });
} else {
userParts.push({
imageData: imageBase64,
mimeType: imageMime ?? "image/png",
@@ -89,8 +89,6 @@ const buildUserParts = ({
if (text.length > 0) {
userParts.push({ text: text, type: "text" });
}
} else {
userParts.push({ text: text, type: "text" });
}
return userParts;
@@ -174,13 +172,18 @@ const threadView = ({
onLoadingChange(true);
onErrorChange(undefined);
const { messages, mode } = thread;
const {
aspectRatio,
imageSize: rawImageSize,
messages,
mode,
} = thread;
const imageSize = rawImageSize ?? "4K";
const userParts = buildUserParts({
/* eslint-disable-next-line stylistic/no-extra-parens -- Required for conditional spread with exactOptionalPropertyTypes */
...(imageBase64 !== undefined && { imageBase64 }),
/* eslint-disable-next-line stylistic/no-extra-parens -- Required for conditional spread with exactOptionalPropertyTypes */
...(imageMime !== undefined && { imageMime }),
mode,
text,
});
@@ -217,7 +220,9 @@ const threadView = ({
"send_message",
{
apiKey,
aspectRatio,
history,
imageSize,
mode,
userImageBase64,
userImageMime,
@@ -253,7 +258,13 @@ const threadView = ({
const handleRetry = useCallback(
(modelMessageIndex: number): void => {
const { messages, mode } = thread;
const {
aspectRatio,
imageSize: rawImageSize,
messages,
mode,
} = thread;
const imageSize = rawImageSize ?? "4K";
const userMessageIndex = modelMessageIndex - 1;
const userMessage = messages[userMessageIndex];
@@ -300,7 +311,9 @@ const threadView = ({
}
const response = await invoke<SendResult>("send_message", {
apiKey,
aspectRatio,
history,
imageSize,
mode,
userImageBase64,
userImageMime,
@@ -353,7 +366,13 @@ const threadView = ({
const handleEditCommit = useCallback(
(messageIndex: number, editedText: string): void => {
const { messages, mode } = thread;
const {
aspectRatio,
imageSize: rawImageSize,
messages,
mode,
} = thread;
const imageSize = rawImageSize ?? "4K";
const originalMessage = messages[messageIndex];
if (originalMessage === undefined) {
return;
@@ -404,7 +423,9 @@ const threadView = ({
}
const response = await invoke<SendResult>("send_message", {
apiKey,
aspectRatio,
history,
imageSize,
mode,
userImageBase64,
userImageMime,
@@ -536,7 +557,11 @@ const threadView = ({
{...(pendingInput?.text !== undefined && {
initialText: pendingInput.text,
})}
{...(thread.aspectRatio !== undefined && {
aspectRatio: thread.aspectRatio,
})}
hasMessages={thread.messages.length > 0}
imageSize={thread.imageSize ?? "4K"}
isLoading={isLoading}
mode={thread.mode}
onInputChange={onPendingInputChange}
+20 -7
View File
@@ -5,6 +5,8 @@
* @author Naomi Carrigan
*/
type AspectRatio = "1:1" | "3:4" | "4:3" | "9:16" | "16:9" | "21:9";
type ImageSize = "1K" | "2K" | "4K";
type Mode = "avatar" | "art" | "replace";
interface Config {
@@ -33,12 +35,23 @@ interface ThreadMessage {
}
interface Thread {
createdAt: number;
id: string;
messages: Array<ThreadMessage>;
mode: Mode;
name: string;
updatedAt: number;
aspectRatio?: AspectRatio;
createdAt: number;
id: string;
imageSize?: ImageSize;
messages: Array<ThreadMessage>;
mode: Mode;
name: string;
updatedAt: number;
}
export type { Config, MessagePart, Mode, PendingInput, Thread, ThreadMessage };
export type {
AspectRatio,
Config,
ImageSize,
MessagePart,
Mode,
PendingInput,
Thread,
ThreadMessage,
};