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
+131
View File
@@ -0,0 +1,131 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
lint-and-check:
name: Lint & Check
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Check dependency pins
uses: naomi-lgbt/dependency-pin-check@main
- name: Install Linux dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
librsvg2-dev \
patchelf \
libgtk-3-dev \
libayatana-appindicator3-dev
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install frontend dependencies
run: pnpm install
- name: Run ESLint
run: pnpm lint
- name: Build frontend
run: pnpm build
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
src-tauri/target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Run Clippy
working-directory: src-tauri
run: cargo clippy --all-targets --all-features -- -D warnings
build-windows:
name: Build Windows
runs-on: ubuntu-latest
needs: lint-and-check
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install Linux dependencies for cross-compilation
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
librsvg2-dev \
patchelf \
libgtk-3-dev \
libayatana-appindicator3-dev \
clang \
lld \
llvm \
nsis
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- name: Install frontend dependencies
run: pnpm install
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
- name: Install cargo-xwin
run: |
curl -fsSL https://github.com/rust-cross/cargo-xwin/releases/download/v0.20.2/cargo-xwin-v0.20.2.x86_64-unknown-linux-musl.tar.gz | tar xz
sudo mv cargo-xwin /usr/local/bin/
- name: Cache Rust dependencies
uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
src-tauri/target/
key: ${{ runner.os }}-cargo-windows-${{ hashFiles('**/Cargo.lock') }}
- name: Build Windows
run: pnpm build:windows
+4
View File
@@ -0,0 +1,4 @@
node_modules/
dist/
src-tauri/target/
src-tauri/gen/
+34 -10
View File
@@ -1,20 +1,44 @@
# New Repository Template
# Tatsumi
This template contains all of our basic files for a new GitHub repository. There is also a handy workflow that will create an issue on a new repository made from this template, with a checklist for the steps we usually take in setting up a new repository.
Tatsumi is a desktop app for generating AI character art powered by Google Gemini's image model. It's built specifically to generate art of Naomi, with her reference sheet pre-loaded so you just describe the scene.
If you're starting a Node.JS project with TypeScript, we have a [specific template](https://github.com/naomi-lgbt/nodejs-typescript-template) for that purpose.
## Features
## Readme
### Three generation modes
Delete all of the above text (including this line), and uncomment the below text to use our standard readme template.
- **Avatar** — Generate a portrait or avatar from a text prompt (1:1 aspect ratio)
- **Art** — Generate a full widescreen piece from a text prompt (16:9 aspect ratio)
- **Replace** — Upload a source image and describe what you want changed or reimagined
<!-- # Project Name
### Conversation threads
Project Description
Each generation session is a thread. You can send follow-up messages to refine the result, retry any response, edit a previous prompt and re-run, or delete messages. Threads are saved locally and persist between sessions.
## Live Version
### Other bits
This page is currently deployed. [View the live website.]
- Model reasoning is shown as a collapsible "Model reasoning" section on each response
- Cost per response is displayed (input + output tokens)
- Generated images can be downloaded directly from the chat
- App version shown in the sidebar header
## Setup
You'll need a [Google Gemini API key](https://aistudio.google.com/apikey) to use Tatsumi. On first launch, you'll be prompted to enter it. You can update it any time via Settings.
## Running locally
```bash
pnpm install
pnpm tauri dev
```
## Building
```bash
pnpm tauri build
# or for Windows cross-compilation:
pnpm build:windows
```
## Feedback and Bugs
@@ -36,4 +60,4 @@ Copyright held by Naomi Carrigan.
## Contact
We may be contacted through our [Chat Server](http://chat.nhcarrigan.com) or via email at `contact@nhcarrigan.com`. -->
We may be contacted through our [Chat Server](https://chat.nhcarrigan.com) or via email at `contact@nhcarrigan.com`.
+13
View File
@@ -0,0 +1,13 @@
import nhcarrigan from "@nhcarrigan/eslint-config";
export default [
...nhcarrigan,
{
ignores: ["dist/", "src-tauri/target/", "node_modules/", "*.config.ts", "*.config.js"],
settings: {
react: {
version: "detect",
},
},
},
];
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/png" href="/src-tauri/icons/32x32.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tatsumi</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+34
View File
@@ -0,0 +1,34 @@
{
"name": "tatsumi",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"build:windows": "tauri build --runner cargo-xwin --target x86_64-pc-windows-msvc",
"lint": "eslint src --max-warnings 0",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "2.5.0",
"@tauri-apps/plugin-dialog": "2.3.0",
"@tauri-apps/plugin-fs": "2.4.0",
"react": "19.1.0",
"react-dom": "19.1.0"
},
"devDependencies": {
"@nhcarrigan/eslint-config": "5.2.0",
"@nhcarrigan/typescript-config": "4.0.0",
"@tauri-apps/cli": "2.5.0",
"@types/react": "19.1.2",
"@types/react-dom": "19.1.2",
"autoprefixer": "10.4.21",
"eslint": "9.25.1",
"postcss": "8.5.3",
"tailwindcss": "3.4.17",
"typescript": "5.8.3",
"vite": "6.3.2",
"@vitejs/plugin-react": "4.4.1"
}
}
+4976
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

+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"
]
}
}
+365
View File
@@ -0,0 +1,365 @@
/**
* @file Root application component managing threads and navigation.
* @copyright Naomi Carrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Component requires state and handlers */
/* eslint-disable max-statements -- Component requires many state variables and handlers */
/* eslint-disable max-lines -- Root component requires many handlers and modals */
import { invoke } from "@tauri-apps/api/core";
import { type JSX, useCallback, useEffect, useState } from "react";
import { AboutModal } from "./components/aboutModal.tsx";
import { NewThreadModal } from "./components/newThreadModal.tsx";
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";
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 generateId = (): string => {
const random = Math.random().
toString(36).
slice(2, 9);
return `${String(Date.now())}-${random}`;
};
const filterThreadsById = (
previous: Array<Thread>,
threadId: string,
): Array<Thread> => {
return previous.filter((thread) => {
return thread.id !== threadId;
});
};
const replaceThreadById = (
previous: Array<Thread>,
finalThread: Thread,
): Array<Thread> => {
return previous.map((thread) => {
return thread.id === finalThread.id
? finalThread
: thread;
});
};
const resolveUpdatedThread = (updatedThread: Thread): Thread => {
const [ firstMessage ] = updatedThread.messages;
if (
updatedThread.messages.length !== 1
|| firstMessage === undefined
|| firstMessage.role !== "user"
) {
return updatedThread;
}
const firstTextPart = firstMessage.parts.find((part) => {
return part.type === "text";
});
const name = generateThreadName(
updatedThread.mode,
firstTextPart?.text,
);
return { ...updatedThread, name };
};
/**
* Renders the root application component with thread management.
* @returns The JSX element.
*/
const app = (): JSX.Element => {
const [ threads, setThreads ] = useState<Array<Thread>>([]);
const [ selectedThreadId, setSelectedThreadId ] = useState<
string | undefined
>(undefined);
const [ showNewThreadModal, setShowNewThreadModal ] = useState(false);
const [ config, setConfig ] = useState<Config>({ apiKey: "" });
const [ showSettings, setShowSettings ] = useState(false);
const [ showAbout, setShowAbout ] = useState(false);
const [ pendingInputs, setPendingInputs ] = useState<
Record<string, PendingInput>
>({});
const [ loadingStartTimes, setLoadingStartTimes ] = useState<
Record<string, number>
>({});
const [ errorMessages, setErrorMessages ] = useState<Record<string, string>>(
{},
);
useEffect(() => {
const loadThreads = async(): Promise<void> => {
try {
const loaded = await invoke<Array<Thread>>("load_threads");
setThreads(loaded);
const loadedConfig = await invoke<Config>("load_config");
setConfig(loadedConfig);
} catch (error) {
/* eslint-disable-next-line no-console -- No logger available */
console.error("Failed to load threads:", error);
}
};
void loadThreads();
}, []);
const selectedThread = threads.find((thread) => {
return thread.id === selectedThreadId;
});
const handleNewThread = useCallback(
(mode: Mode): void => {
const now = Date.now();
const createdThread: Thread = {
createdAt: now,
id: generateId(),
messages: [],
mode: mode,
name: `New ${mode} thread`,
updatedAt: now,
};
setThreads((previous) => {
return [ createdThread, ...previous ];
});
setSelectedThreadId(createdThread.id);
setShowNewThreadModal(false);
},
[],
);
const handleThreadUpdate = useCallback(
(updatedThread: Thread): void => {
const finalThread = resolveUpdatedThread(updatedThread);
setThreads((previous) => {
return replaceThreadById(previous, finalThread);
});
},
[],
);
const handlePendingInputChange = useCallback(
(threadId: string, input: PendingInput | undefined): void => {
setPendingInputs((previous) => {
if (input === undefined) {
const next = { ...previous };
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- Required to remove a specific key from a Record
delete next[threadId];
return next;
}
return { ...previous, [threadId]: input };
});
},
[],
);
const handleLoadingChange = useCallback(
(threadId: string, loading: boolean): void => {
setLoadingStartTimes((previous) => {
if (!loading) {
const next = { ...previous };
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- Required to remove a specific key from a Record
delete next[threadId];
return next;
}
return { ...previous, [threadId]: Date.now() };
});
},
[],
);
const handleErrorChange = useCallback(
(threadId: string, error: string | undefined): void => {
setErrorMessages((previous) => {
if (error === undefined) {
const next = { ...previous };
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- Required to remove a specific key from a Record
delete next[threadId];
return next;
}
return { ...previous, [threadId]: error };
});
},
[],
);
const handleDeleteThread = useCallback(
(threadId: string): void => {
setThreads((previous) => {
return filterThreadsById(previous, threadId);
});
if (selectedThreadId === threadId) {
setSelectedThreadId(undefined);
}
setLoadingStartTimes((previous) => {
const next = { ...previous };
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- Required to remove a specific key from a Record
delete next[threadId];
return next;
});
setErrorMessages((previous) => {
const next = { ...previous };
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete -- Required to remove a specific key from a Record
delete next[threadId];
return next;
});
},
[ selectedThreadId ],
);
const handleSelectThread = useCallback(
(thread: Thread): void => {
setSelectedThreadId(thread.id);
},
[],
);
const handleOpenNewThreadModal = useCallback((): void => {
setShowNewThreadModal(true);
}, []);
const handleCloseNewThreadModal = useCallback((): void => {
setShowNewThreadModal(false);
}, []);
const handleOpenSettings = useCallback((): void => {
setShowSettings(true);
}, []);
const handleOpenAbout = useCallback((): void => {
setShowAbout(true);
}, []);
const handleCloseAbout = useCallback((): void => {
setShowAbout(false);
}, []);
const handleCloseSettings = useCallback((): void => {
if (config.apiKey.length > 0) {
setShowSettings(false);
}
}, [ config.apiKey ]);
const handleSaveConfig = useCallback(
async(apiKey: string): Promise<void> => {
const updatedConfig: Config = { apiKey };
await invoke("save_config", { config: updatedConfig });
setConfig(updatedConfig);
setShowSettings(false);
},
[],
);
const handleSaveConfigVoid = useCallback(
(apiKey: string): void => {
void handleSaveConfig(apiKey);
},
[ handleSaveConfig ],
);
const selectedThreadId2 = selectedThread?.id;
const handleSelectedThreadPendingInputChange = useCallback(
(input: PendingInput | undefined): void => {
if (selectedThreadId2 !== undefined) {
handlePendingInputChange(selectedThreadId2, input);
}
},
[ selectedThreadId2, handlePendingInputChange ],
);
const handleSelectedThreadLoadingChange = useCallback(
(loading: boolean): void => {
if (selectedThreadId2 !== undefined) {
handleLoadingChange(selectedThreadId2, loading);
}
},
[ selectedThreadId2, handleLoadingChange ],
);
const handleSelectedThreadErrorChange = useCallback(
(error: string | undefined): void => {
if (selectedThreadId2 !== undefined) {
handleErrorChange(selectedThreadId2, error);
}
},
[ selectedThreadId2, handleErrorChange ],
);
const shouldShowSettings = showSettings || config.apiKey.length === 0;
return (
<div
className="flex h-screen w-screen overflow-hidden bg-[#0f0a1a] text-white"
>
<Sidebar
onAbout={handleOpenAbout}
onDeleteThread={handleDeleteThread}
onNewThread={handleOpenNewThreadModal}
onSelectThread={handleSelectThread}
onSettings={handleOpenSettings}
selectedThreadId={selectedThreadId}
threads={threads}
/>
<main className="flex-1 overflow-hidden">
{selectedThread === undefined
? <WelcomeScreen onNewThread={handleOpenNewThreadModal} />
: <ThreadView
apiKey={config.apiKey}
isLoading={loadingStartTimes[selectedThread.id] !== undefined}
key={selectedThread.id}
onErrorChange={handleSelectedThreadErrorChange}
onLoadingChange={handleSelectedThreadLoadingChange}
onPendingInputChange={handleSelectedThreadPendingInputChange}
onUpdate={handleThreadUpdate}
{...(errorMessages[selectedThread.id] !== undefined && {
errorMessage: errorMessages[selectedThread.id],
})}
{...(loadingStartTimes[selectedThread.id] !== undefined && {
loadingStartTime: loadingStartTimes[selectedThread.id],
})}
{...(pendingInputs[selectedThread.id] !== undefined && {
pendingInput: pendingInputs[selectedThread.id],
})}
thread={selectedThread}
/>
}
</main>
{showNewThreadModal
? <NewThreadModal
onClose={handleCloseNewThreadModal}
onSelect={handleNewThread}
/>
: null}
{shouldShowSettings
? <SettingsModal
currentApiKey={config.apiKey}
onClose={handleCloseSettings}
onSave={handleSaveConfigVoid}
/>
: null}
{showAbout
? <AboutModal onClose={handleCloseAbout} />
: null}
</div>
);
};
export { app as App };
+167
View File
@@ -0,0 +1,167 @@
/**
* @file About modal explaining what Tatsumi is and how to use it.
* @copyright Naomi Carrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Modal JSX inherently requires many lines */
import { invoke } from "@tauri-apps/api/core";
import { type JSX, type MouseEvent, useCallback } from "react";
const overlayClass = [
"fixed inset-0 bg-black/70 backdrop-blur-sm",
"flex items-center justify-center z-50",
].join(" ");
const modalClass = [
"bg-[#1a1028] border border-purple-900/40 rounded-2xl",
"p-8 max-w-lg w-full mx-4 shadow-2xl",
].join(" ");
const linkClass = [
"text-purple-400 hover:text-purple-300 underline",
"transition-colors cursor-pointer",
].join(" ");
interface AboutModalProperties {
readonly onClose: ()=> void;
}
/**
* Renders the About modal explaining Tatsumi and how to use it.
* @param props - The component props.
* @param props.onClose - Callback to close the modal.
* @returns The JSX element.
*/
const aboutModal = ({ onClose }: AboutModalProperties): JSX.Element => {
const handleOpenDiscord = useCallback((): void => {
void invoke("open_url", { url: "https://chat.nhcarrigan.com" });
}, []);
const handleContentClick = useCallback(
(clickEvent: MouseEvent): void => {
clickEvent.stopPropagation();
},
[],
);
return (
<div
className={overlayClass}
onClick={onClose}
>
<div
className={modalClass}
onClick={handleContentClick}
>
<div className="flex items-center gap-3 mb-6">
<img
alt="Tatsumi"
className="w-10 h-10 rounded-full object-cover"
src="/tatsumi.png"
/>
<h2 className="text-xl font-bold text-white">
{"About Tatsumi"}
</h2>
</div>
<div className="space-y-4 text-gray-300 text-sm leading-relaxed">
<p>
{
"Tatsumi is an AI art generation app powered by Google Gemini's "
+ "image model. It's built specifically to generate character "
+ "art of Naomi."
}
</p>
<div>
<h3 className="text-white font-semibold mb-2">
{"Three modes"}
</h3>
<ul className="space-y-1.5 list-none">
<li>
<span className="text-purple-300 font-medium">
{"Avatar"}
</span>
{
" — Generate a square portrait of Naomi from a text prompt."
}
</li>
<li>
<span className="text-pink-300 font-medium">
{"Art"}
</span>
{
" — Generate a full widescreen art piece of Naomi "
+ "from a text prompt."
}
</li>
<li>
<span className="text-blue-300 font-medium">
{"Replace"}
</span>
{
" — Upload an anime girl art and get the character "
+ "replaced with Naomi."
}
</li>
</ul>
</div>
<div
className={
"bg-purple-900/20 border border-purple-900/40 rounded-xl p-4"
}
>
<p className="text-purple-200 font-medium mb-1">
{"✨ Naomi's reference sheet is pre-loaded"}
</p>
<p className="text-gray-400 text-xs">
{
"All three modes automatically include Naomi's character "
+ "reference sheet and a full character description on the "
+ "first message, so the model knows exactly how she looks. "
+ "You just describe the scene, pose, or what to change!"
}
</p>
</div>
<p>
{
"This app is designed specifically to generate art of Naomi. "
+ "If you use it to make something you're proud of, come share "
+ "it with us — we'd love to see what you create!"
}
</p>
<p>
{"Join the community on "}
<button
className={linkClass}
onClick={handleOpenDiscord}
type="button"
>
{"our Discord server"}
</button>
{" and share your art in the community channels."}
</p>
</div>
<div className="mt-6 flex justify-end">
<button
className={
"bg-purple-700 hover:bg-purple-600 text-white font-semibold "
+ "py-2 px-6 rounded-xl transition-colors"
}
onClick={onClose}
type="button"
>
{"Got it!"}
</button>
</div>
</div>
</div>
);
};
export { aboutModal as AboutModal };
+484
View File
@@ -0,0 +1,484 @@
/**
* @file Input area component for composing and sending messages.
* @copyright Naomi Carrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Component requires complex file handling and send logic */
/* eslint-disable max-lines -- Clipboard and file handling requires many handlers */
/* eslint-disable complexity -- Replace mode has conditional branches for initial vs follow-up state */
/* eslint-disable max-statements -- Component requires many state variables and handlers */
import {
useCallback,
useRef,
useState,
type ChangeEvent,
type ClipboardEvent,
type DragEvent,
type JSX,
type KeyboardEvent,
} from "react";
import type { Mode, PendingInput } from "../types/index.ts";
const dropZoneBaseClass = [
"border-2 border-dashed rounded-xl p-8",
"text-center cursor-pointer transition-all duration-200",
].join(" ");
const dropZoneActiveClass = "border-blue-400 bg-blue-900/20";
const dropZoneInactiveClass = [
"border-purple-900/40",
"hover:border-blue-500/60 hover:bg-blue-900/10",
].join(" ");
const replaceButtonClass = [
"w-full bg-blue-600 hover:bg-blue-500",
"disabled:bg-gray-700 disabled:cursor-not-allowed",
"text-white font-semibold py-3 rounded-xl transition-all duration-200",
].join(" ");
const sendButtonClass = [
"bg-gradient-to-r from-purple-600 to-pink-600",
"hover:from-purple-500 hover:to-pink-500",
"disabled:from-gray-700 disabled:to-gray-700 disabled:cursor-not-allowed",
"text-white font-semibold py-3 px-5 rounded-xl",
"transition-all duration-200 min-w-[80px]",
].join(" ");
const textareaClass = [
"flex-1 bg-[#241836] border border-purple-900/40",
"focus:border-purple-500/60 rounded-xl px-4 py-3",
"text-white placeholder-gray-600 resize-none outline-none",
"transition-colors text-sm",
].join(" ");
const clearButtonClass = [
"absolute top-1 right-1 bg-black/60 hover:bg-black/80",
"text-white rounded-full w-6 h-6 flex items-center",
"justify-center text-xs transition-colors",
].join(" ");
const pasteButtonClass = [
"w-full border border-purple-900/40 hover:border-purple-500/60",
"bg-transparent hover:bg-purple-900/10 text-gray-400 hover:text-gray-200",
"text-sm py-2 rounded-xl transition-all duration-200",
].join(" ");
interface BuildPendingInputOptions {
readonly imageBase64?: string;
readonly imageMime?: string;
readonly imagePreview?: string;
readonly text: string;
}
const buildPendingInput = ({
imageBase64,
imageMime,
imagePreview,
text,
}: BuildPendingInputOptions): PendingInput | undefined => {
const hasText = text.trim().length > 0;
const hasImage = imageBase64 !== undefined;
if (!hasText && !hasImage) {
return undefined;
}
return {
/* eslint-disable-next-line stylistic/no-extra-parens -- Required for conditional spread with exactOptionalPropertyTypes */
...(hasImage && { imageBase64, imageMime, imagePreview }),
/* eslint-disable-next-line stylistic/no-extra-parens -- Required for conditional spread with exactOptionalPropertyTypes */
...(hasText && { text }),
};
};
const readClipboardImage = async(
onFileRead: (file: File)=> void,
): Promise<void> => {
const clipboardItems = await navigator.clipboard.read();
const imageItem = clipboardItems.find((item) => {
return item.types.some((type) => {
return type.startsWith("image/");
});
});
if (imageItem === undefined) {
return;
}
const imageType = imageItem.types.find((type) => {
return type.startsWith("image/");
});
if (imageType === undefined) {
return;
}
const blob = await imageItem.getType(imageType);
onFileRead(new File([ blob ], "clipboard.png", { type: imageType }));
};
const modeLabelText = (mode: Mode): string => {
if (mode === "avatar") {
return "🟣 Avatar Mode (1:1)";
}
if (mode === "art") {
return "🩷 Art Mode (16:9)";
}
return "🔵 Replace Mode";
};
type OnSendCallback = (
text: string,
imageBase64?: string,
imageMime?: string,
)=> void;
interface InputAreaProperties {
readonly hasMessages: boolean;
readonly initialImageBase64?: string;
readonly initialImageMime?: string;
readonly initialImagePreview?: string;
readonly initialText?: string;
readonly isLoading: boolean;
readonly mode: Mode;
readonly onInputChange?: (input: PendingInput | undefined)=> void;
readonly onSend: OnSendCallback;
}
/**
* Renders the input area for composing and sending messages.
* @param props - The component props.
* @param props.hasMessages - Whether the thread already has messages (affects replace mode UI).
* @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.
* @param props.initialText - Initial draft text to pre-populate.
* @param props.isLoading - Whether a message is currently being sent.
* @param props.mode - The current generation mode.
* @param props.onInputChange - Callback when any pending input (text or image) changes.
* @param props.onSend - Callback to send a message.
* @returns The JSX element.
*/
const inputArea = ({
hasMessages,
initialImageBase64,
initialImageMime,
initialImagePreview,
initialText,
isLoading,
mode,
onInputChange,
onSend,
}: InputAreaProperties): JSX.Element => {
const [ text, setText ] = useState(initialText ?? "");
const [ imageBase64, setImageBase64 ] = useState<string | undefined>(
initialImageBase64,
);
const [ imageMime, setImageMime ] = useState<string | undefined>(
initialImageMime,
);
const [ imagePreview, setImagePreview ] = useState<string | undefined>(
initialImagePreview,
);
const [ isDragging, setIsDragging ] = useState(false);
const fileInputReference = useRef<HTMLInputElement>(null);
const handleFileRead = useCallback((file: File): void => {
const reader = new FileReader();
reader.addEventListener("load", (event) => {
const dataUrl = event.target?.result;
if (typeof dataUrl !== "string") {
return;
}
const [ , base64Data ] = dataUrl.split(",");
if (base64Data === undefined) {
return;
}
const mimeType = file.type.length > 0
? file.type
: "image/png";
setImageBase64(base64Data);
setImageMime(mimeType);
setImagePreview(dataUrl);
onInputChange?.(buildPendingInput({
imageBase64: base64Data,
imageMime: mimeType,
imagePreview: dataUrl,
text: text,
}));
});
reader.readAsDataURL(file);
}, [ onInputChange, text ]);
const handleFileChange = useCallback(
(event: ChangeEvent<HTMLInputElement>): void => {
const file = event.target.files?.[0];
if (file !== undefined) {
handleFileRead(file);
}
},
[ handleFileRead ],
);
const handleDrop = useCallback(
(event: DragEvent): void => {
event.preventDefault();
setIsDragging(false);
const [ file ] = event.dataTransfer.files;
if (file?.type.startsWith("image/") === true) {
handleFileRead(file);
}
},
[ handleFileRead ],
);
const handleDragOver = useCallback((event: DragEvent): void => {
event.preventDefault();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((): void => {
setIsDragging(false);
}, []);
const clearImage = useCallback((): void => {
setImageBase64(undefined);
setImageMime(undefined);
setImagePreview(undefined);
if (fileInputReference.current !== null) {
fileInputReference.current.value = "";
}
onInputChange?.(buildPendingInput({ text }));
}, [ onInputChange, text ]);
const isInitialReplace = mode === "replace" && !hasMessages;
const handleSend = useCallback((): void => {
if (isLoading) {
return;
}
if (isInitialReplace && imageBase64 === undefined) {
return;
}
if (!isInitialReplace && text.trim().length === 0) {
return;
}
onSend(text, imageBase64, imageMime);
setText("");
setImageBase64(undefined);
setImageMime(undefined);
setImagePreview(undefined);
onInputChange?.(undefined);
}, [
isLoading,
isInitialReplace,
imageBase64,
text,
imageMime,
onSend,
onInputChange,
]);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLTextAreaElement>): void => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleSend();
}
},
[ handleSend ],
);
const handleDropZoneClick = useCallback((): void => {
fileInputReference.current?.click();
}, []);
const handlePaste = useCallback(
(event: ClipboardEvent<HTMLDivElement>): void => {
const imageItem = [ ...event.clipboardData.items ].find((item) => {
return item.type.startsWith("image/");
});
if (imageItem !== undefined) {
const file = imageItem.getAsFile();
if (file !== null) {
handleFileRead(file);
}
}
},
[ handleFileRead ],
);
const handlePasteButtonClick = useCallback((): void => {
void readClipboardImage(handleFileRead);
}, [ handleFileRead ]);
const handleTextChange = useCallback(
(event: ChangeEvent<HTMLTextAreaElement>): void => {
setText(event.target.value);
onInputChange?.(buildPendingInput({
/* 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 }),
/* eslint-disable-next-line stylistic/no-extra-parens -- Required for conditional spread with exactOptionalPropertyTypes */
...(imagePreview !== undefined && { imagePreview }),
text: event.target.value,
}));
},
[ onInputChange, imageBase64, imageMime, imagePreview ],
);
const dropZoneClass = [
dropZoneBaseClass,
isDragging
? dropZoneActiveClass
: dropZoneInactiveClass,
].join(" ");
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)}
</span>
</div>
{isInitialReplace
? <div className="flex flex-col gap-3">
{imagePreview === undefined
? <div className="flex flex-col gap-2">
<div
className={dropZoneClass}
onClick={handleDropZoneClick}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onPaste={handlePaste}
role="button"
tabIndex={0}
>
<div className="text-3xl mb-2">{"📁"}</div>
<p className="text-gray-400 text-sm">
{"Drop an image here, or "}
<span className="text-blue-400 underline">
{"click to browse"}
</span>
</p>
<p className="text-gray-600 text-xs mt-1">
{"PNG, JPG, WEBP supported"}
</p>
</div>
<button
className={pasteButtonClass}
onClick={handlePasteButtonClick}
type="button"
>
{"📋 Paste from clipboard"}
</button>
</div>
: <div className="relative inline-block">
<img
alt="Upload preview"
className="max-h-40 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"
/>
<button
className={replaceButtonClass}
disabled={isLoading || imageBase64 === undefined}
onClick={handleSend}
type="button"
>
{isLoading
? <span className="flex items-center justify-center gap-2">
<span className="animate-spin">{"⟳"}</span>
{" Generating..."}
</span>
: "Replace Image ✨"}
</button>
</div>
: <div className="flex flex-col gap-3">
{mode === "replace"
? <div className="flex flex-col gap-2">
{imagePreview === undefined
? <button
className={pasteButtonClass}
onClick={handlePasteButtonClick}
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 className="flex gap-3 items-end">
<textarea
className={textareaClass}
disabled={isLoading}
onChange={handleTextChange}
onKeyDown={handleKeyDown}
placeholder={mode === "replace"
? "Give correction instructions (e.g. fix the eye colour)..."
: "Describe the art you want to generate..."}
rows={3}
value={text}
/>
<button
className={sendButtonClass}
disabled={isLoading || text.trim().length === 0}
onClick={handleSend}
type="button"
>
{isLoading
? <span className="flex items-center gap-1">
<span className="animate-spin inline-block">{"⟳"}</span>
</span>
: "Send ✨"}
</button>
</div>
</div>}
</div>
);
};
export { inputArea as InputArea };
+340
View File
@@ -0,0 +1,340 @@
/**
* @file Message bubble component for displaying chat messages.
* @copyright Naomi Carrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Component JSX inherently requires many lines */
/* eslint-disable max-statements -- Component requires many callbacks and state variables */
/* eslint-disable complexity -- UI component has inherently complex conditional rendering */
import { invoke } from "@tauri-apps/api/core";
import {
useCallback,
useState,
type ChangeEvent,
type JSX,
type KeyboardEvent,
type MouseEvent,
} from "react";
import type { MessagePart, ThreadMessage } from "../types/index.ts";
const modelBubbleClass = [
"bg-[#241836] text-gray-100",
"rounded-tl-sm border border-purple-900/20",
].join(" ");
const thoughtContentClass = [
"mt-1 text-xs text-gray-400 italic",
"border-l-2 border-purple-800/40 pl-2",
"leading-relaxed whitespace-pre-wrap",
].join(" ");
const downloadButtonClass = [
"text-gray-400 hover:text-white text-xs mt-1 transition-colors",
].join(" ");
const footerActionClass = [
"text-gray-500 hover:text-purple-300 text-xs transition-colors",
].join(" ");
const deleteActionClass = [
"text-gray-500 hover:text-red-400 text-xs transition-colors",
].join(" ");
const thoughtButtonClass = [
"text-xs text-purple-400 hover:text-purple-300 flex items-center gap-1",
].join(" ");
const editTextareaClass = [
"w-full bg-[#0f0a1a] border border-purple-700/40 rounded-lg",
"px-3 py-2 text-sm text-white resize-none focus:outline-none",
"focus:border-purple-500/60 min-h-[80px]",
].join(" ");
const editSaveButtonClass = [
"text-xs bg-purple-700 hover:bg-purple-600",
"text-white px-3 py-1 rounded-lg transition-colors",
].join(" ");
const editCancelButtonClass = [
"text-xs text-gray-400 hover:text-gray-200 transition-colors",
].join(" ");
interface MessageBubbleProperties {
readonly message: ThreadMessage;
readonly onDelete?: ()=> void;
readonly onEdit?: (editedText: string)=> void;
readonly onRetry?: ()=> void;
}
/**
* Renders a single message bubble in the thread view.
* @param props - The component props.
* @param props.message - The message to display.
* @param props.onDelete - Optional callback to delete this message and all following.
* @param props.onEdit - Optional callback to update the user message text and re-run.
* @param props.onRetry - Optional callback to retry generating this response.
* @returns The JSX element.
*/
const messageBubble = ({
message,
onDelete,
onEdit,
onRetry,
}: MessageBubbleProperties): JSX.Element => {
const isUser = message.role === "user";
const bubbleClass = isUser
? "bg-purple-700/60 text-white rounded-tr-sm"
: modelBubbleClass;
const [ thoughtExpanded, setThoughtExpanded ] = useState(false);
const [ isHovered, setIsHovered ] = useState(false);
const [ isEditing, setIsEditing ] = useState(false);
const [ editingText, setEditingText ] = useState("");
const originalTextPart = message.parts.find((part) => {
return part.type === "text";
});
const toggleThought = useCallback((): void => {
setThoughtExpanded((previous) => {
return !previous;
});
}, []);
const handleMouseEnter = useCallback((): void => {
setIsHovered(true);
}, []);
const handleMouseLeave = useCallback((): void => {
setIsHovered(false);
}, []);
const handleEditStart = useCallback((): void => {
setEditingText(originalTextPart?.text ?? "");
setIsEditing(true);
}, [ originalTextPart ]);
const handleEditCancel = useCallback((): void => {
setIsEditing(false);
}, []);
const handleEditSave = useCallback((): void => {
setIsEditing(false);
onEdit?.(editingText);
}, [ editingText, onEdit ]);
const handleEditChange = useCallback(
(changeEvent: ChangeEvent<HTMLTextAreaElement>): void => {
setEditingText(changeEvent.target.value);
},
[],
);
const handleEditKeyDown = useCallback(
(keyEvent: KeyboardEvent<HTMLTextAreaElement>): void => {
if (keyEvent.key === "Enter" && (keyEvent.ctrlKey || keyEvent.metaKey)) {
handleEditSave();
}
if (keyEvent.key === "Escape") {
handleEditCancel();
}
},
[ handleEditSave, handleEditCancel ],
);
const handleDownloadClick = useCallback(
(event: MouseEvent<HTMLButtonElement>): void => {
const { imageData, mimeType } = event.currentTarget.dataset;
if (imageData !== undefined && mimeType !== undefined) {
const extension = mimeType.includes("jpeg") || mimeType.includes("jpg")
? "jpg"
: "png";
const fileName = `Generated_Image_${String(Date.now())}.${extension}`;
void invoke("save_image", {
base64Data: imageData,
fileName: fileName,
mimeType: mimeType,
});
}
},
[],
);
const renderThoughtPart = (key: string, text: string): JSX.Element => {
return (
<div className="mb-2" key={key}>
<button
className={thoughtButtonClass}
onClick={toggleThought}
type="button"
>
<span>{thoughtExpanded
? "▼"
: "▶"}</span>
<span>{"Model reasoning"}</span>
</button>
{thoughtExpanded
? <div className={thoughtContentClass}>
{text}
</div>
: null}
</div>
);
};
const renderImagePart = (key: string, part: MessagePart): JSX.Element => {
const { imageData = "" } = part;
const mimeType = part.mimeType ?? "image/png";
const source = `data:${mimeType};base64,${imageData}`;
return (
<div className="mt-2" key={key}>
<img
alt="Generated artwork"
className="max-w-[50%] rounded-lg"
src={source}
/>
{isUser
? null
: <button
className={downloadButtonClass}
data-image-data={imageData}
data-mime-type={mimeType}
onClick={handleDownloadClick}
type="button"
>
{"⬇ Download"}
</button>}
</div>
);
};
const renderPart = (
part: MessagePart,
partIndex: number,
): JSX.Element | null => {
const key = `part-${String(partIndex)}`;
if (part.type === "thought" && part.text !== undefined && !isUser) {
return renderThoughtPart(key, part.text);
}
if (part.type === "text" && part.text !== undefined && !isEditing) {
return (
<p
className="text-sm leading-relaxed whitespace-pre-wrap"
key={key}
>
{part.text}
</p>
);
}
if (part.type === "image" && part.imageData !== undefined) {
return renderImagePart(key, part);
}
return null;
};
const editArea = isEditing
? <div className="mt-2 space-y-2">
<textarea
autoFocus={true}
className={editTextareaClass}
onChange={handleEditChange}
onKeyDown={handleEditKeyDown}
value={editingText}
/>
<div className="flex gap-2">
<button
className={editSaveButtonClass}
onClick={handleEditSave}
type="button"
>
{"✓ Save & Re-run"}
</button>
<button
className={editCancelButtonClass}
onClick={handleEditCancel}
type="button"
>
{"✗ Cancel"}
</button>
</div>
</div>
: null;
const showUserActions = isUser && (isHovered || isEditing) && !isEditing;
const userFooter = showUserActions
? <div className="border-t border-purple-700/20 mt-2 pt-1 flex gap-3">
{originalTextPart !== undefined && onEdit !== undefined
? <button
className={footerActionClass}
onClick={handleEditStart}
type="button"
>
{"✏️ Edit"}
</button>
: null}
{onDelete === undefined
? null
: <button
className={deleteActionClass}
onClick={onDelete}
type="button"
>
{"🗑 Delete"}
</button>}
</div>
: null;
return (
<div
className={`flex w-full mb-4 ${isUser
? "justify-end"
: "justify-start"}`}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className={`max-w-[80%] rounded-2xl px-4 py-3 ${bubbleClass}`}>
{message.parts.map((part: MessagePart, partIndex: number) => {
return renderPart(part, partIndex);
})}
{editArea}
{userFooter}
{isUser
? null
: <>
{message.cost === undefined
? null
: <p className="text-xs text-gray-500 mt-2 text-right">
{`Cost: $${message.cost.toFixed(4)}`}
</p>}
<div className="border-t border-purple-900/20 mt-2 pt-1 flex gap-3">
{onRetry === undefined
? null
: <button
className={footerActionClass}
onClick={onRetry}
type="button"
>
{"🔄 Retry"}
</button>}
{onDelete === undefined
? null
: <button
className={deleteActionClass}
onClick={onDelete}
type="button"
>
{"🗑 Delete"}
</button>}
</div>
</>}
</div>
</div>
);
};
export { messageBubble as MessageBubble };
+183
View File
@@ -0,0 +1,183 @@
/**
* @file Modal for selecting a new thread generation mode.
* @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";
interface ModeOption {
colour: string;
description: string;
icon: string;
label: string;
mode: Mode;
}
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.",
].join(" ");
const replaceDescription = [
"Upload an image and have Naomi redrawn in",
"anime style with the same pose and outfit.",
].join(" ");
const modeOptionList: Array<ModeOption> = [
{
colour: "purple",
description: avatarDescription,
icon: "👤",
label: "Avatar Mode",
mode: "avatar",
},
{
colour: "pink",
description: artDescription,
icon: "🎨",
label: "Art Mode",
mode: "art",
},
{
colour: "blue",
description: replaceDescription,
icon: "🔄",
label: "Replace Mode",
mode: "replace",
},
];
const colourMap: Record<
string,
{ badge: string; button: string; hover: string }
> = {
blue: {
badge: "bg-blue-900/40 text-blue-300",
button: "bg-blue-600 hover:bg-blue-500",
hover: "hover:border-blue-400 hover:bg-blue-900/20",
},
pink: {
badge: "bg-pink-900/40 text-pink-300",
button: "bg-pink-600 hover:bg-pink-500",
hover: "hover:border-pink-400 hover:bg-pink-900/20",
},
purple: {
badge: "bg-purple-900/40 text-purple-300",
button: "bg-purple-600 hover:bg-purple-500",
hover: "hover:border-purple-400 hover:bg-purple-900/20",
},
};
const validModesSet = new Set<string>([ "avatar", "art", "replace" ]);
const isMode = (value: string): value is Mode => {
return validModesSet.has(value);
};
const overlayClass = [
"fixed inset-0 bg-black/70 backdrop-blur-sm",
"flex items-center justify-center z-50",
].join(" ");
const panelClass = [
"bg-[#1a1028] border border-purple-900/40",
"rounded-2xl p-8 max-w-2xl w-full mx-4",
"shadow-2xl shadow-purple-900/30",
].join(" ");
interface NewThreadModalProperties {
readonly onClose: ()=> void;
readonly onSelect: (mode: Mode)=> void;
}
/**
* Renders the new thread modal for selecting a generation mode.
* @param props - The component props.
* @param props.onClose - Callback to close the modal.
* @param props.onSelect - Callback when a mode is selected.
* @returns The JSX element.
*/
const threadModal = ({
onClose,
onSelect,
}: NewThreadModalProperties): JSX.Element => {
const handleModeSelect = useCallback(
(event: MouseEvent<HTMLButtonElement>): void => {
const rawMode = event.currentTarget.dataset.mode;
if (rawMode !== undefined && isMode(rawMode)) {
onSelect(rawMode);
}
},
[ onSelect ],
);
return (
<div className={overlayClass}>
<div className={panelClass}>
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold text-white">
{"New Thread"}
</h2>
<button
className="text-gray-400 hover:text-white transition-colors"
onClick={onClose}
type="button"
>
{"×"}
</button>
</div>
<p className="text-gray-400 mb-6">
{"Choose a generation mode to begin:"}
</p>
<div className="flex flex-col gap-4">
{modeOptionList.map((option) => {
const colours = colourMap[option.colour];
const buttonClass = [
"flex items-center gap-4 p-5 rounded-xl",
"border border-purple-900/30 bg-[#241836]",
"text-left transition-all duration-200",
colours?.hover ?? "",
].join(" ");
return (
<button
className={buttonClass}
data-mode={option.mode}
key={option.mode}
onClick={handleModeSelect}
type="button"
>
<span className="text-4xl">{option.icon}</span>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-white font-semibold text-lg">
{option.label}
</span>
<span
className={`text-xs px-2 py-0.5 rounded-full ${colours?.badge ?? ""}`}
>
{option.mode}
</span>
</div>
<p className="text-gray-400 text-sm">{option.description}</p>
</div>
<span className="text-gray-500 text-xl">{"→"}</span>
</button>
);
})}
</div>
</div>
</div>
);
};
export { threadModal as NewThreadModal };
+154
View File
@@ -0,0 +1,154 @@
/**
* @file Settings modal component for configuring the application API key.
* @copyright Naomi Carrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Component JSX inherently requires many lines */
import { type ChangeEvent, useCallback, useState, type JSX } from "react";
const overlayClass = [
"fixed inset-0 bg-black/70 backdrop-blur-sm",
"flex items-center justify-center z-50",
].join(" ");
const panelClass = [
"bg-[#1a1028] border border-purple-900/40",
"rounded-2xl p-8 max-w-lg w-full mx-4",
"shadow-2xl shadow-purple-900/30",
].join(" ");
const saveButtonClass = [
"bg-gradient-to-r from-purple-600 to-pink-600",
"hover:from-purple-500 hover:to-pink-500",
"text-white font-semibold py-2 px-6 rounded-xl",
"transition-all duration-200 text-sm",
].join(" ");
const cancelButtonClass = [
"bg-transparent border border-purple-900/40",
"hover:border-purple-500/60 text-gray-400 hover:text-white",
"font-semibold py-2 px-6 rounded-xl",
"transition-all duration-200 text-sm",
].join(" ");
const inputClass = [
"flex-1 bg-[#0f0a1a] border border-purple-900/40",
"rounded-xl px-4 py-2.5 text-white text-sm",
"focus:outline-none focus:border-purple-500/60",
"placeholder-gray-600 font-mono",
].join(" ");
interface SettingsModalProperties {
readonly currentApiKey: string;
readonly onClose: ()=> void;
readonly onSave: (apiKey: string)=> void;
}
/**
* Renders the settings modal for configuring the Gemini API key.
* @param props - The component props.
* @param props.currentApiKey - The currently configured API key.
* @param props.onClose - Callback to close the modal.
* @param props.onSave - Callback when the API key is saved.
* @returns The JSX element.
*/
const settingsModal = ({
currentApiKey,
onClose,
onSave,
}: SettingsModalProperties): JSX.Element => {
const [ apiKey, setApiKey ] = useState(currentApiKey);
const [ showKey, setShowKey ] = useState(false);
const handleApiKeyChange = useCallback(
(event: ChangeEvent<HTMLInputElement>): void => {
setApiKey(event.target.value);
},
[],
);
const handleToggleShow = useCallback((): void => {
setShowKey((previous) => {
return !previous;
});
}, []);
const handleSave = useCallback((): void => {
onSave(apiKey);
}, [ apiKey, onSave ]);
return (
<div className={overlayClass}>
<div className={panelClass}>
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold text-white">{"Settings"}</h2>
<button
className="text-gray-400 hover:text-white transition-colors"
onClick={onClose}
type="button"
>
{"×"}
</button>
</div>
<div className="mb-6">
<label
className="block text-sm font-medium text-gray-300 mb-2"
htmlFor="api-key-input"
>
{"Gemini API Key"}
</label>
<div className="flex gap-2">
<input
className={inputClass}
id="api-key-input"
onChange={handleApiKeyChange}
placeholder="Enter your Gemini API key..."
type={showKey
? "text"
: "password"}
value={apiKey}
/>
<button
className="text-gray-400 hover:text-white transition-colors px-3"
onClick={handleToggleShow}
title={showKey
? "Hide API key"
: "Show API key"}
type="button"
>
{"👁"}
</button>
</div>
<p className="text-xs text-gray-600 mt-2">
{"Your API key is stored locally on this device."}
</p>
</div>
<div className="flex justify-end gap-3">
<button
className={cancelButtonClass}
onClick={onClose}
type="button"
>
{"Cancel"}
</button>
<button
className={saveButtonClass}
onClick={handleSave}
type="button"
>
{"Save"}
</button>
</div>
</div>
</div>
);
};
export { settingsModal as SettingsModal };
+324
View File
@@ -0,0 +1,324 @@
/**
* @file Sidebar component showing thread list and navigation.
* @copyright Naomi Carrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Component JSX inherently requires many lines */
import { getVersion } from "@tauri-apps/api/app";
import { invoke } from "@tauri-apps/api/core";
import {
useState,
useCallback,
useEffect,
type JSX,
type MouseEvent,
} from "react";
import type { Mode, Thread } from "../types/index.ts";
const deleteButtonClass = [
"shrink-0 text-gray-600 hover:text-red-400",
"transition-colors text-sm",
].join(" ");
const addThreadButtonClass = [
"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-2.5 rounded-xl",
"transition-all duration-200 text-sm",
"shadow-lg shadow-purple-900/20",
].join(" ");
const settingsButtonClass = [
"w-full text-left text-gray-500 hover:text-gray-300",
"transition-colors text-sm py-1.5 px-2 rounded-lg hover:bg-purple-900/15",
].join(" ");
const iconButtonClass = [
"flex-1 text-gray-500 hover:text-gray-300",
"transition-colors text-sm py-1.5 px-2 rounded-lg",
"hover:bg-purple-900/15 text-center",
].join(" ");
const sidebarClass = [
"w-[280px] min-w-[280px] bg-[#1a1028]",
"border-r border-purple-900/30 flex flex-col h-full",
].join(" ");
const modeBadge = (mode: Mode): string => {
if (mode === "avatar") {
return "bg-purple-900/40 text-purple-300";
}
if (mode === "art") {
return "bg-pink-900/40 text-pink-300";
}
return "bg-blue-900/40 text-blue-300";
};
const modeLabel = (mode: Mode): string => {
if (mode === "avatar") {
return "Avatar";
}
if (mode === "art") {
return "Art";
}
return "Replace";
};
const formatDate = (timestamp: number): string => {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffDays === 0) {
return date.toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
}
if (diffDays === 1) {
return "Yesterday";
}
if (diffDays < 7) {
return `${String(diffDays)}d ago`;
}
return date.toLocaleDateString([], {
day: "numeric",
month: "short",
});
};
interface SidebarProperties {
readonly onAbout: ()=> void;
readonly onDeleteThread: (threadId: string)=> void;
readonly onNewThread: ()=> void;
readonly onSelectThread: (thread: Thread)=> void;
readonly onSettings: ()=> void;
readonly selectedThreadId: string | undefined;
readonly threads: Array<Thread>;
}
/**
* Renders the sidebar with thread list and navigation controls.
* @param props - The component props.
* @param props.onAbout - Callback to open the about modal.
* @param props.onDeleteThread - Callback to delete a thread.
* @param props.onNewThread - Callback to open new thread modal.
* @param props.onSelectThread - Callback to select a thread.
* @param props.onSettings - Callback to open the settings modal.
* @param props.selectedThreadId - Currently selected thread ID.
* @param props.threads - List of threads to display.
* @returns The JSX element.
*/
const sidebar = ({
onAbout,
onDeleteThread,
onNewThread,
onSelectThread,
onSettings,
selectedThreadId,
threads,
}: SidebarProperties): JSX.Element => {
const [ hoveredId, setHoveredId ] = useState<string | undefined>(undefined);
const [ appVersion, setAppVersion ] = useState<string>("");
useEffect((): void => {
void getVersion().then((version) => {
setAppVersion(version);
});
}, []);
const handleOpenDonate = useCallback((): void => {
void invoke("open_url", { url: "https://donate.nhcarrigan.com" });
}, []);
const handleOpenChat = useCallback((): void => {
void invoke("open_url", { url: "https://chat.nhcarrigan.com" });
}, []);
const sortedThreads = [ ...threads ].sort((threadA, threadB) => {
return threadB.updatedAt - threadA.updatedAt;
});
const handleThreadClick = useCallback(
(event: MouseEvent<HTMLDivElement>): void => {
const { threadId } = event.currentTarget.dataset;
if (threadId === undefined) {
return;
}
const thread = threads.find((t) => {
return t.id === threadId;
});
if (thread !== undefined) {
onSelectThread(thread);
}
},
[ threads, onSelectThread ],
);
const handleMouseEnter = useCallback(
(event: MouseEvent<HTMLDivElement>): void => {
const { threadId } = event.currentTarget.dataset;
if (threadId !== undefined) {
setHoveredId(threadId);
}
},
[],
);
const handleMouseLeave = useCallback((): void => {
setHoveredId(undefined);
}, []);
const executeDelete = useCallback(
async(threadId: string): Promise<void> => {
await invoke("delete_thread", { threadId });
onDeleteThread(threadId);
},
[ onDeleteThread ],
);
const handleDeleteClick = useCallback(
(event: MouseEvent<HTMLButtonElement>): void => {
event.stopPropagation();
const { threadId } = event.currentTarget.dataset;
if (threadId !== undefined) {
void executeDelete(threadId);
}
},
[ executeDelete ],
);
return (
<div className={sidebarClass}>
<div className="px-4 py-5 border-b border-purple-900/30">
<div className="flex items-center gap-3 mb-1">
<img
alt="Tatsumi"
className="w-10 h-10 rounded-full object-cover"
src="/tatsumi.png"
/>
<div>
<h1 className="text-white font-bold text-lg leading-tight">
{"Tatsumi"}
</h1>
<p className="text-gray-600 text-xs">
{appVersion.length > 0
? `v${appVersion}`
: ""}
</p>
</div>
</div>
<button
className={addThreadButtonClass}
onClick={onNewThread}
type="button"
>
{"+ New Thread"}
</button>
</div>
<div className="flex-1 overflow-y-auto py-2">
{sortedThreads.length === 0
? <div className="px-4 py-8 text-center text-gray-600 text-sm">
{"No threads yet. Create one to get started!"}
</div>
: null}
{sortedThreads.map((thread) => {
const isSelected = selectedThreadId === thread.id;
const isHovered = hoveredId === thread.id;
const threadItemClass = [
"flex items-start gap-3 px-4 py-3",
"cursor-pointer transition-all duration-150 relative group",
isSelected
? "bg-purple-900/30 border-r-2 border-purple-500"
: "hover:bg-purple-900/15",
].join(" ");
const badgeClass = [
"text-xs px-1.5 py-0.5 rounded-full font-medium shrink-0",
modeBadge(thread.mode),
].join(" ");
return (
<div
className={threadItemClass}
data-thread-id={thread.id}
key={thread.id}
onClick={handleThreadClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className={badgeClass}>
{modeLabel(thread.mode)}
</span>
</div>
<p className="text-sm text-gray-200 truncate leading-tight">
{thread.name}
</p>
<p className="text-xs text-gray-600 mt-0.5">
{formatDate(thread.updatedAt)}
</p>
</div>
{isHovered || isSelected
? <button
className={deleteButtonClass}
data-thread-id={thread.id}
onClick={handleDeleteClick}
title="Delete thread"
type="button"
>
{"🗑"}
</button>
: null}
</div>
);
})}
</div>
<div className="px-4 py-3 border-t border-purple-900/30 space-y-1">
<div className="flex gap-1">
<button
className={iconButtonClass}
onClick={handleOpenDonate}
title="Donate"
type="button"
>
{"💜 Donate"}
</button>
<button
className={iconButtonClass}
onClick={handleOpenChat}
title="Chat"
type="button"
>
{"💬 Chat"}
</button>
<button
className={iconButtonClass}
onClick={onAbout}
title="About"
type="button"
>
{"️ About"}
</button>
</div>
<button
className={settingsButtonClass}
onClick={onSettings}
type="button"
>
{"⚙️ Settings"}
</button>
</div>
</div>
);
};
export { sidebar as Sidebar };
+549
View File
@@ -0,0 +1,549 @@
/**
* @file Thread view component for displaying and sending messages.
* @copyright Naomi Carrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Component requires complex async logic */
/* eslint-disable complexity -- Component renders conditionally based on many state values */
/* eslint-disable max-lines -- Retry and send logic requires many lines */
/* eslint-disable react/jsx-no-bind -- Per-message index closures require inline handlers in map */
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useRef, useState, type JSX } from "react";
import { InputArea } from "./inputArea.tsx";
import { MessageBubble } from "./messageBubble.tsx";
import type {
MessagePart,
Mode,
PendingInput,
Thread,
ThreadMessage,
} from "../types/index.ts";
const headerClass = [
"flex items-center gap-3 px-6 py-4",
"border-b border-purple-900/30 bg-[#1a1028]/50",
].join(" ");
const loadingBubbleClass = [
"bg-[#241836] border border-purple-900/20",
"rounded-2xl rounded-tl-sm px-4 py-3",
].join(" ");
const errorBubbleClass = [
"bg-red-900/20 border border-red-700/40",
"rounded-2xl rounded-tl-sm px-4 py-3 max-w-[80%]",
].join(" ");
const errorRetryButtonClass = [
"text-red-400 hover:text-red-200 text-xs mt-2 transition-colors block",
].join(" ");
const dotOneDelay = { animationDelay: "0ms" };
const dotTwoDelay = { animationDelay: "150ms" };
const dotThreeDelay = { animationDelay: "300ms" };
const dotClass = "w-2 h-2 bg-purple-400 rounded-full animate-bounce";
const modeBadgeColour = (mode: Mode): string => {
if (mode === "avatar") {
return "bg-purple-900/40 text-purple-300";
}
if (mode === "art") {
return "bg-pink-900/40 text-pink-300";
}
return "bg-blue-900/40 text-blue-300";
};
const modeLabel = (mode: Mode): string => {
if (mode === "avatar") {
return "Avatar";
}
if (mode === "art") {
return "Art";
}
return "Replace";
};
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) {
userParts.push({
imageData: imageBase64,
mimeType: imageMime ?? "image/png",
type: "image",
});
if (text.length > 0) {
userParts.push({ text: text, type: "text" });
}
} else {
userParts.push({ text: text, type: "text" });
}
return userParts;
};
interface ThreadViewProperties {
readonly apiKey: string;
readonly errorMessage?: string;
readonly isLoading: boolean;
readonly loadingStartTime?: number;
readonly onErrorChange: (error: string | undefined)=> void;
readonly onLoadingChange: (loading: boolean)=> void;
readonly onPendingInputChange: (input: PendingInput | undefined)=> void;
readonly onUpdate: (thread: Thread)=> void;
readonly pendingInput?: PendingInput;
readonly thread: Thread;
}
/**
* Renders the thread view with messages and input area.
* @param props - The component props.
* @param props.apiKey - The Gemini API key to use for requests.
* @param props.errorMessage - The persisted error message for this thread, if any.
* @param props.isLoading - Whether a generation is currently in progress.
* @param props.loadingStartTime - Timestamp when loading started, for the elapsed timer.
* @param props.onErrorChange - Callback to update the error message in the parent.
* @param props.onLoadingChange - Callback to update the loading state in the parent.
* @param props.onPendingInputChange - Callback when the pending input changes.
* @param props.onUpdate - Callback when thread is updated.
* @param props.pendingInput - The persisted pending input for this thread.
* @param props.thread - The thread to display.
* @returns The JSX element.
*/
const threadView = ({
apiKey,
errorMessage,
isLoading,
loadingStartTime,
onErrorChange,
onLoadingChange,
onPendingInputChange,
onUpdate,
pendingInput,
thread,
}: ThreadViewProperties): JSX.Element => {
const [ elapsedSeconds, setElapsedSeconds ] = useState(() => {
return loadingStartTime === undefined
? 0
: Math.floor((Date.now() - loadingStartTime) / 1000);
});
const messagesEndReference = useRef<HTMLDivElement>(null);
useEffect((): VoidFunction | undefined => {
if (isLoading) {
const startTime = loadingStartTime ?? Date.now();
const interval = setInterval(() => {
setElapsedSeconds(Math.floor((Date.now() - startTime) / 1000));
}, 1000);
return (): void => {
clearInterval(interval);
};
}
setElapsedSeconds(0);
return undefined;
}, [ isLoading, loadingStartTime ]);
const scrollToBottom = useCallback((): void => {
messagesEndReference.current?.scrollIntoView({ behavior: "smooth" });
}, []);
useEffect(() => {
scrollToBottom();
}, [ thread.messages, isLoading, scrollToBottom ]);
const handleSend = useCallback(
(
text: string,
imageBase64?: string,
imageMime?: string,
): void => {
onLoadingChange(true);
onErrorChange(undefined);
const { messages, mode } = thread;
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,
});
const userMessage: ThreadMessage = {
parts: userParts,
role: "user",
};
const historyForApi = messages;
const updatedThread: Thread = {
...thread,
messages: [ ...messages, userMessage ],
updatedAt: Date.now(),
};
onUpdate(updatedThread);
const performSend = async(): Promise<void> => {
try {
const history = historyForApi;
const userImageBase64 = imageBase64 ?? undefined;
const userImageMime = imageMime ?? undefined;
const userText = text.length > 0
? text
: undefined;
await invoke("save_thread", { thread: updatedThread });
interface SendResult {
costUsd: number;
parts: Array<MessagePart>;
}
const response = await invoke<SendResult>(
"send_message",
{
apiKey,
history,
mode,
userImageBase64,
userImageMime,
userText,
},
);
const modelMessage: ThreadMessage = {
cost: response.costUsd,
parts: response.parts,
role: "model",
};
const finalThread: Thread = {
...updatedThread,
messages: [ ...updatedThread.messages, modelMessage ],
updatedAt: Date.now(),
};
await invoke("save_thread", { thread: finalThread });
onUpdate(finalThread);
} catch (error) {
onErrorChange(String(error));
} finally {
onLoadingChange(false);
}
};
void performSend();
},
[ thread, onUpdate, onLoadingChange, onErrorChange ],
);
const handleRetry = useCallback(
(modelMessageIndex: number): void => {
const { messages, mode } = thread;
const userMessageIndex = modelMessageIndex - 1;
const userMessage = messages[userMessageIndex];
if (userMessage === undefined || userMessage.role !== "user") {
return;
}
const textPart = userMessage.parts.find((p) => {
return p.type === "text";
});
const imagePart = userMessage.parts.find((p) => {
return p.type === "image";
});
// Use key names matching the invoke parameters for shorthand compatibility
const rawText = textPart?.text ?? "";
const userText = rawText.length > 0
? rawText
: undefined;
const userImageBase64 = imagePart?.imageData;
const userImageMime = imagePart?.mimeType;
// Keep the user message, drop the model response (and anything after)
const keptMessages = messages.slice(0, modelMessageIndex);
const history = messages.slice(0, userMessageIndex);
onLoadingChange(true);
onErrorChange(undefined);
const keptThread: Thread = {
...thread,
messages: keptMessages,
updatedAt: Date.now(),
};
onUpdate(keptThread);
const performRetry = async(): Promise<void> => {
try {
await invoke("save_thread", { thread: keptThread });
interface SendResult {
costUsd: number;
parts: Array<MessagePart>;
}
const response = await invoke<SendResult>("send_message", {
apiKey,
history,
mode,
userImageBase64,
userImageMime,
userText,
});
const modelMessage: ThreadMessage = {
cost: response.costUsd,
parts: response.parts,
role: "model",
};
const finalThread: Thread = {
...keptThread,
messages: [ ...keptMessages, modelMessage ],
updatedAt: Date.now(),
};
await invoke("save_thread", { thread: finalThread });
onUpdate(finalThread);
} catch (error) {
onErrorChange(String(error));
} finally {
onLoadingChange(false);
}
};
void performRetry();
},
[ thread, apiKey, onUpdate, onLoadingChange, onErrorChange ],
);
const handleErrorRetry = useCallback((): void => {
handleRetry(thread.messages.length);
}, [ handleRetry, thread ]);
const handleDelete = useCallback(
(messageIndex: number): void => {
const trimmedMessages = thread.messages.slice(0, messageIndex);
const updatedThread: Thread = {
...thread,
messages: trimmedMessages,
updatedAt: Date.now(),
};
onUpdate(updatedThread);
void invoke("save_thread", { thread: updatedThread });
},
[ thread, onUpdate ],
);
const handleEditCommit = useCallback(
(messageIndex: number, editedText: string): void => {
const { messages, mode } = thread;
const originalMessage = messages[messageIndex];
if (originalMessage === undefined) {
return;
}
const updatedParts = originalMessage.parts.map((part) => {
return part.type === "text"
? { ...part, text: editedText }
: part;
});
const updatedUserMessage: ThreadMessage = {
...originalMessage,
parts: updatedParts,
};
const history = messages.slice(0, messageIndex);
const imagePart = updatedParts.find((part) => {
return part.type === "image";
});
const userImageBase64 = imagePart?.imageData;
const userImageMime = imagePart?.mimeType;
const userText = editedText.length > 0
? editedText
: undefined;
const keptMessages = [
...messages.slice(0, messageIndex),
updatedUserMessage,
];
const keptThread: Thread = {
...thread,
messages: keptMessages,
updatedAt: Date.now(),
};
onLoadingChange(true);
onErrorChange(undefined);
onUpdate(keptThread);
const performEdit = async(): Promise<void> => {
try {
await invoke("save_thread", { thread: keptThread });
interface SendResult {
costUsd: number;
parts: Array<MessagePart>;
}
const response = await invoke<SendResult>("send_message", {
apiKey,
history,
mode,
userImageBase64,
userImageMime,
userText,
});
const modelMessage: ThreadMessage = {
cost: response.costUsd,
parts: response.parts,
role: "model",
};
const finalThread: Thread = {
...keptThread,
messages: [ ...keptMessages, modelMessage ],
updatedAt: Date.now(),
};
await invoke("save_thread", { thread: finalThread });
onUpdate(finalThread);
} catch (error) {
onErrorChange(String(error));
} finally {
onLoadingChange(false);
}
};
void performEdit();
},
[ thread, apiKey, onUpdate, onLoadingChange, onErrorChange ],
);
return (
<div className="flex flex-col h-full">
<div className={headerClass}>
<h2 className="text-white font-semibold flex-1 truncate">
{thread.name}
</h2>
<span
className={`text-xs px-2 py-1 rounded-full font-medium ${modeBadgeColour(thread.mode)}`}
>
{modeLabel(thread.mode)}
</span>
</div>
<div className="flex-1 overflow-y-auto px-6 py-4">
{thread.messages.length === 0
? <div
className={[
"flex items-center justify-center",
"h-full text-gray-500 text-sm",
].join(" ")}
>
{"Start by sending your first prompt!"}
</div>
: null}
{thread.messages.map((message, messageIndex) => {
return (
<MessageBubble
key={`message-${String(messageIndex)}`}
message={message}
onDelete={(): void => {
handleDelete(messageIndex);
}}
{...(message.role === "user" && {
onEdit: (editedText: string): void => {
handleEditCommit(messageIndex, editedText);
},
})}
{...(message.role === "model" && {
onRetry: (): void => {
handleRetry(messageIndex);
},
})}
/>
);
})}
{isLoading
? <div className="flex justify-start mb-4">
<div className={loadingBubbleClass}>
<div className="flex items-center gap-2 text-gray-400">
<div className="flex gap-1">
<span className={dotClass} style={dotOneDelay} />
<span className={dotClass} style={dotTwoDelay} />
<span className={dotClass} style={dotThreeDelay} />
</div>
<span className="text-sm">{`Generating... (${String(elapsedSeconds)}s)`}</span>
</div>
</div>
</div>
: null}
{errorMessage === undefined
? null
: <div className="flex justify-start mb-4">
<div className={errorBubbleClass}>
<p className="text-red-300 text-sm">
{`⚠️ Error: ${errorMessage}`}
</p>
<button
className={errorRetryButtonClass}
disabled={isLoading}
onClick={handleErrorRetry}
type="button"
>
{"🔄 Retry"}
</button>
</div>
</div>}
<div ref={messagesEndReference} />
</div>
<InputArea
{...(pendingInput?.imageBase64 !== undefined && {
initialImageBase64: pendingInput.imageBase64,
})}
{...(pendingInput?.imageMime !== undefined && {
initialImageMime: pendingInput.imageMime,
})}
{...(pendingInput?.imagePreview !== undefined && {
initialImagePreview: pendingInput.imagePreview,
})}
{...(pendingInput?.text !== undefined && {
initialText: pendingInput.text,
})}
hasMessages={thread.messages.length > 0}
isLoading={isLoading}
mode={thread.mode}
onInputChange={onPendingInputChange}
onSend={handleSend}
/>
</div>
);
};
export { threadView as ThreadView };
+96
View File
@@ -0,0 +1,96 @@
/**
* @file Welcome screen displayed when no thread is selected.
* @copyright Naomi Carrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Component JSX inherently requires many lines */
import type { JSX } from "react";
const buttonClass = [
"bg-gradient-to-r from-purple-600 to-pink-600",
"hover:from-purple-500 hover:to-pink-500",
"text-white font-semibold py-3 px-8 rounded-xl",
"transition-all duration-200 text-lg",
"shadow-lg shadow-purple-900/30",
].join(" ");
const cardClass = "bg-[#241836] rounded-xl p-6 border border-purple-900/30";
const containerClass = [
"flex flex-col items-center justify-center",
"h-full gap-8 text-center px-8",
].join(" ");
const headingClass = [
"text-4xl font-bold bg-gradient-to-r from-purple-400 to-pink-400",
"bg-clip-text text-transparent",
].join(" ");
interface WelcomeScreenProperties {
readonly onNewThread: ()=> void;
}
/**
* Renders the welcome screen with mode descriptions and a new thread button.
* @param props - The component props.
* @param props.onNewThread - Callback to open the new thread modal.
* @returns The JSX element.
*/
const welcomeScreen = ({
onNewThread,
}: WelcomeScreenProperties): JSX.Element => {
return (
<div className={containerClass}>
<div className="flex flex-col items-center gap-4">
<div className="text-7xl">{"✨"}</div>
<h1 className={headingClass}>{"Tatsumi"}</h1>
<p className="text-gray-300 text-lg max-w-lg leading-relaxed">
{
"Hi! I'm Tatsumi — NHCarrigan's Chief Design Officer. "
+ "My siren magic goes into everything I make, and I've poured "
+ "it into this too. Tell me how you want Naomi to look, "
+ "and I'll bring her to life~"
}
</p>
</div>
<div className="grid grid-cols-3 gap-4 max-w-2xl w-full">
<div className={cardClass}>
<div className="text-3xl mb-3">{"👤"}</div>
<h3 className="text-purple-400 font-semibold mb-2">
{"Avatar Mode"}
</h3>
<p className="text-gray-400 text-sm">
{"Generate a square portrait of Naomi"}
</p>
</div>
<div className={cardClass}>
<div className="text-3xl mb-3">{"🎨"}</div>
<h3 className="text-pink-400 font-semibold mb-2">{"Art Mode"}</h3>
<p className="text-gray-400 text-sm">
{"Generate a widescreen art piece of Naomi"}
</p>
</div>
<div className={cardClass}>
<div className="text-3xl mb-3">{"🔄"}</div>
<h3 className="text-blue-400 font-semibold mb-2">
{"Replace Mode"}
</h3>
<p className="text-gray-400 text-sm">
{"Upload anime girl art and replace her with Naomi"}
</p>
</div>
</div>
<button
className={buttonClass}
onClick={onNewThread}
type="button"
>
{"+ New Thread"}
</button>
</div>
);
};
export { welcomeScreen as WelcomeScreen };
+42
View File
@@ -0,0 +1,42 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body,
#root {
height: 100%;
width: 100%;
overflow: hidden;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif;
background-color: #0f0a1a;
color: white;
-webkit-font-smoothing: antialiased;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #3b1f5e;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #5b3f7e;
}
+24
View File
@@ -0,0 +1,24 @@
/**
* @file Application entry point for Tatsumi.
* @copyright Naomi Carrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable import/no-unassigned-import -- CSS import has no exports to assign */
import "./index.css";
/* eslint-enable import/no-unassigned-import -- CSS import has no exports to assign */
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./app.tsx";
const rootElement = document.getElementById("root");
if (rootElement === null) {
throw new Error("Root element not found");
}
createRoot(rootElement).render(
<StrictMode>
<App />
</StrictMode>,
);
+44
View File
@@ -0,0 +1,44 @@
/**
* @file Type definitions for the Tatsumi application.
* @copyright Naomi Carrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
type Mode = "avatar" | "art" | "replace";
interface Config {
apiKey: string;
}
interface PendingInput {
imageBase64?: string;
imageMime?: string;
imagePreview?: string;
text?: string;
}
interface MessagePart {
imageData?: string;
mimeType?: string;
text?: string;
thoughtSignature?: string;
type: "image" | "text" | "thought";
}
interface ThreadMessage {
cost?: number;
parts: Array<MessagePart>;
role: "user" | "model";
}
interface Thread {
createdAt: number;
id: string;
messages: Array<ThreadMessage>;
mode: Mode;
name: string;
updatedAt: number;
}
export type { Config, MessagePart, Mode, PendingInput, Thread, ThreadMessage };
+16
View File
@@ -0,0 +1,16 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
extend: {
colors: {
"studio-bg": "#0f0a1a",
"studio-sidebar": "#1a1028",
"studio-card": "#241836",
"studio-purple": "#8b5cf6",
"studio-pink": "#ec4899",
},
},
},
plugins: [],
};
+15
View File
@@ -0,0 +1,15 @@
{
"extends": "@nhcarrigan/typescript-config",
"compilerOptions": {
"outDir": "./dist",
"rootDir": ".",
"jsx": "react-jsx",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"target": "ES2020",
"module": "ESNext"
},
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": ["node_modules", "src-tauri"]
}
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
clearScreen: false,
server: {
port: 1420,
strictPort: true,
},
envPrefix: ["VITE_", "TAURI_ENV_*"],
build: {
target: "chrome105",
minify: !process.env.TAURI_ENV_DEBUG ? "esbuild" : false,
sourcemap: !!process.env.TAURI_ENV_DEBUG,
},
});