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

Tatsumi is a Tauri desktop app for generating AI character art of Naomi
using Google Gemini's image model. Features three generation modes
(avatar, art, replace), persistent conversation threads, message
editing and deletion, retry support, cost tracking, and an about modal
with lore-accurate self-introduction from Emi Carrigan.
This commit is contained in:
2026-04-09 20:16:54 -07:00
committed by Naomi Carrigan
parent cf544434d3
commit f2c4fb34b7
84 changed files with 14229 additions and 10 deletions
+484
View File
@@ -0,0 +1,484 @@
/**
* @file Input area component for composing and sending messages.
* @copyright Naomi Carrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
/* eslint-disable max-lines-per-function -- Component requires complex file handling and send logic */
/* eslint-disable max-lines -- Clipboard and file handling requires many handlers */
/* eslint-disable complexity -- Replace mode has conditional branches for initial vs follow-up state */
/* eslint-disable max-statements -- Component requires many state variables and handlers */
import {
useCallback,
useRef,
useState,
type ChangeEvent,
type ClipboardEvent,
type DragEvent,
type JSX,
type KeyboardEvent,
} from "react";
import type { Mode, PendingInput } from "../types/index.ts";
const dropZoneBaseClass = [
"border-2 border-dashed rounded-xl p-8",
"text-center cursor-pointer transition-all duration-200",
].join(" ");
const dropZoneActiveClass = "border-blue-400 bg-blue-900/20";
const dropZoneInactiveClass = [
"border-purple-900/40",
"hover:border-blue-500/60 hover:bg-blue-900/10",
].join(" ");
const replaceButtonClass = [
"w-full bg-blue-600 hover:bg-blue-500",
"disabled:bg-gray-700 disabled:cursor-not-allowed",
"text-white font-semibold py-3 rounded-xl transition-all duration-200",
].join(" ");
const sendButtonClass = [
"bg-gradient-to-r from-purple-600 to-pink-600",
"hover:from-purple-500 hover:to-pink-500",
"disabled:from-gray-700 disabled:to-gray-700 disabled:cursor-not-allowed",
"text-white font-semibold py-3 px-5 rounded-xl",
"transition-all duration-200 min-w-[80px]",
].join(" ");
const textareaClass = [
"flex-1 bg-[#241836] border border-purple-900/40",
"focus:border-purple-500/60 rounded-xl px-4 py-3",
"text-white placeholder-gray-600 resize-none outline-none",
"transition-colors text-sm",
].join(" ");
const clearButtonClass = [
"absolute top-1 right-1 bg-black/60 hover:bg-black/80",
"text-white rounded-full w-6 h-6 flex items-center",
"justify-center text-xs transition-colors",
].join(" ");
const pasteButtonClass = [
"w-full border border-purple-900/40 hover:border-purple-500/60",
"bg-transparent hover:bg-purple-900/10 text-gray-400 hover:text-gray-200",
"text-sm py-2 rounded-xl transition-all duration-200",
].join(" ");
interface BuildPendingInputOptions {
readonly imageBase64?: string;
readonly imageMime?: string;
readonly imagePreview?: string;
readonly text: string;
}
const buildPendingInput = ({
imageBase64,
imageMime,
imagePreview,
text,
}: BuildPendingInputOptions): PendingInput | undefined => {
const hasText = text.trim().length > 0;
const hasImage = imageBase64 !== undefined;
if (!hasText && !hasImage) {
return undefined;
}
return {
/* eslint-disable-next-line stylistic/no-extra-parens -- Required for conditional spread with exactOptionalPropertyTypes */
...(hasImage && { imageBase64, imageMime, imagePreview }),
/* eslint-disable-next-line stylistic/no-extra-parens -- Required for conditional spread with exactOptionalPropertyTypes */
...(hasText && { text }),
};
};
const readClipboardImage = async(
onFileRead: (file: File)=> void,
): Promise<void> => {
const clipboardItems = await navigator.clipboard.read();
const imageItem = clipboardItems.find((item) => {
return item.types.some((type) => {
return type.startsWith("image/");
});
});
if (imageItem === undefined) {
return;
}
const imageType = imageItem.types.find((type) => {
return type.startsWith("image/");
});
if (imageType === undefined) {
return;
}
const blob = await imageItem.getType(imageType);
onFileRead(new File([ blob ], "clipboard.png", { type: imageType }));
};
const modeLabelText = (mode: Mode): string => {
if (mode === "avatar") {
return "🟣 Avatar Mode (1:1)";
}
if (mode === "art") {
return "🩷 Art Mode (16:9)";
}
return "🔵 Replace Mode";
};
type OnSendCallback = (
text: string,
imageBase64?: string,
imageMime?: string,
)=> void;
interface InputAreaProperties {
readonly hasMessages: boolean;
readonly initialImageBase64?: string;
readonly initialImageMime?: string;
readonly initialImagePreview?: string;
readonly initialText?: string;
readonly isLoading: boolean;
readonly mode: Mode;
readonly onInputChange?: (input: PendingInput | undefined)=> void;
readonly onSend: OnSendCallback;
}
/**
* Renders the input area for composing and sending messages.
* @param props - The component props.
* @param props.hasMessages - Whether the thread already has messages (affects replace mode UI).
* @param props.initialImageBase64 - Initial base64 image data to pre-populate.
* @param props.initialImageMime - Initial image MIME type to pre-populate.
* @param props.initialImagePreview - Initial image preview URL to pre-populate.
* @param props.initialText - Initial draft text to pre-populate.
* @param props.isLoading - Whether a message is currently being sent.
* @param props.mode - The current generation mode.
* @param props.onInputChange - Callback when any pending input (text or image) changes.
* @param props.onSend - Callback to send a message.
* @returns The JSX element.
*/
const inputArea = ({
hasMessages,
initialImageBase64,
initialImageMime,
initialImagePreview,
initialText,
isLoading,
mode,
onInputChange,
onSend,
}: InputAreaProperties): JSX.Element => {
const [ text, setText ] = useState(initialText ?? "");
const [ imageBase64, setImageBase64 ] = useState<string | undefined>(
initialImageBase64,
);
const [ imageMime, setImageMime ] = useState<string | undefined>(
initialImageMime,
);
const [ imagePreview, setImagePreview ] = useState<string | undefined>(
initialImagePreview,
);
const [ isDragging, setIsDragging ] = useState(false);
const fileInputReference = useRef<HTMLInputElement>(null);
const handleFileRead = useCallback((file: File): void => {
const reader = new FileReader();
reader.addEventListener("load", (event) => {
const dataUrl = event.target?.result;
if (typeof dataUrl !== "string") {
return;
}
const [ , base64Data ] = dataUrl.split(",");
if (base64Data === undefined) {
return;
}
const mimeType = file.type.length > 0
? file.type
: "image/png";
setImageBase64(base64Data);
setImageMime(mimeType);
setImagePreview(dataUrl);
onInputChange?.(buildPendingInput({
imageBase64: base64Data,
imageMime: mimeType,
imagePreview: dataUrl,
text: text,
}));
});
reader.readAsDataURL(file);
}, [ onInputChange, text ]);
const handleFileChange = useCallback(
(event: ChangeEvent<HTMLInputElement>): void => {
const file = event.target.files?.[0];
if (file !== undefined) {
handleFileRead(file);
}
},
[ handleFileRead ],
);
const handleDrop = useCallback(
(event: DragEvent): void => {
event.preventDefault();
setIsDragging(false);
const [ file ] = event.dataTransfer.files;
if (file?.type.startsWith("image/") === true) {
handleFileRead(file);
}
},
[ handleFileRead ],
);
const handleDragOver = useCallback((event: DragEvent): void => {
event.preventDefault();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((): void => {
setIsDragging(false);
}, []);
const clearImage = useCallback((): void => {
setImageBase64(undefined);
setImageMime(undefined);
setImagePreview(undefined);
if (fileInputReference.current !== null) {
fileInputReference.current.value = "";
}
onInputChange?.(buildPendingInput({ text }));
}, [ onInputChange, text ]);
const isInitialReplace = mode === "replace" && !hasMessages;
const handleSend = useCallback((): void => {
if (isLoading) {
return;
}
if (isInitialReplace && imageBase64 === undefined) {
return;
}
if (!isInitialReplace && text.trim().length === 0) {
return;
}
onSend(text, imageBase64, imageMime);
setText("");
setImageBase64(undefined);
setImageMime(undefined);
setImagePreview(undefined);
onInputChange?.(undefined);
}, [
isLoading,
isInitialReplace,
imageBase64,
text,
imageMime,
onSend,
onInputChange,
]);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLTextAreaElement>): void => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleSend();
}
},
[ handleSend ],
);
const handleDropZoneClick = useCallback((): void => {
fileInputReference.current?.click();
}, []);
const handlePaste = useCallback(
(event: ClipboardEvent<HTMLDivElement>): void => {
const imageItem = [ ...event.clipboardData.items ].find((item) => {
return item.type.startsWith("image/");
});
if (imageItem !== undefined) {
const file = imageItem.getAsFile();
if (file !== null) {
handleFileRead(file);
}
}
},
[ handleFileRead ],
);
const handlePasteButtonClick = useCallback((): void => {
void readClipboardImage(handleFileRead);
}, [ handleFileRead ]);
const handleTextChange = useCallback(
(event: ChangeEvent<HTMLTextAreaElement>): void => {
setText(event.target.value);
onInputChange?.(buildPendingInput({
/* eslint-disable-next-line stylistic/no-extra-parens -- Required for conditional spread with exactOptionalPropertyTypes */
...(imageBase64 !== undefined && { imageBase64 }),
/* eslint-disable-next-line stylistic/no-extra-parens -- Required for conditional spread with exactOptionalPropertyTypes */
...(imageMime !== undefined && { imageMime }),
/* eslint-disable-next-line stylistic/no-extra-parens -- Required for conditional spread with exactOptionalPropertyTypes */
...(imagePreview !== undefined && { imagePreview }),
text: event.target.value,
}));
},
[ onInputChange, imageBase64, imageMime, imagePreview ],
);
const dropZoneClass = [
dropZoneBaseClass,
isDragging
? dropZoneActiveClass
: dropZoneInactiveClass,
].join(" ");
return (
<div className="border-t border-purple-900/30 p-4 bg-[#0f0a1a]">
<div className="flex items-center gap-2 mb-2">
<span className="text-xs text-gray-500 uppercase tracking-wider">
{modeLabelText(mode)}
</span>
</div>
{isInitialReplace
? <div className="flex flex-col gap-3">
{imagePreview === undefined
? <div className="flex flex-col gap-2">
<div
className={dropZoneClass}
onClick={handleDropZoneClick}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
onPaste={handlePaste}
role="button"
tabIndex={0}
>
<div className="text-3xl mb-2">{"📁"}</div>
<p className="text-gray-400 text-sm">
{"Drop an image here, or "}
<span className="text-blue-400 underline">
{"click to browse"}
</span>
</p>
<p className="text-gray-600 text-xs mt-1">
{"PNG, JPG, WEBP supported"}
</p>
</div>
<button
className={pasteButtonClass}
onClick={handlePasteButtonClick}
type="button"
>
{"📋 Paste from clipboard"}
</button>
</div>
: <div className="relative inline-block">
<img
alt="Upload preview"
className="max-h-40 rounded-lg border border-purple-700/40"
src={imagePreview}
/>
<button
className={clearButtonClass}
onClick={clearImage}
type="button"
>
{"×"}
</button>
</div>}
<input
accept="image/*"
className="hidden"
onChange={handleFileChange}
ref={fileInputReference}
type="file"
/>
<button
className={replaceButtonClass}
disabled={isLoading || imageBase64 === undefined}
onClick={handleSend}
type="button"
>
{isLoading
? <span className="flex items-center justify-center gap-2">
<span className="animate-spin">{"⟳"}</span>
{" Generating..."}
</span>
: "Replace Image ✨"}
</button>
</div>
: <div className="flex flex-col gap-3">
{mode === "replace"
? <div className="flex flex-col gap-2">
{imagePreview === undefined
? <button
className={pasteButtonClass}
onClick={handlePasteButtonClick}
type="button"
>
{"📋 Paste replacement image (optional)"}
</button>
: <div className="relative inline-block">
<img
alt="Upload preview"
className="max-h-32 rounded-lg border border-purple-700/40"
src={imagePreview}
/>
<button
className={clearButtonClass}
onClick={clearImage}
type="button"
>
{"×"}
</button>
</div>}
<input
accept="image/*"
className="hidden"
onChange={handleFileChange}
ref={fileInputReference}
type="file"
/>
</div>
: null}
<div className="flex gap-3 items-end">
<textarea
className={textareaClass}
disabled={isLoading}
onChange={handleTextChange}
onKeyDown={handleKeyDown}
placeholder={mode === "replace"
? "Give correction instructions (e.g. fix the eye colour)..."
: "Describe the art you want to generate..."}
rows={3}
value={text}
/>
<button
className={sendButtonClass}
disabled={isLoading || text.trim().length === 0}
onClick={handleSend}
type="button"
>
{isLoading
? <span className="flex items-center gap-1">
<span className="animate-spin inline-block">{"⟳"}</span>
</span>
: "Send ✨"}
</button>
</div>
</div>}
</div>
);
};
export { inputArea as InputArea };