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