generated from nhcarrigan/template
f2c4fb34b7
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.
550 lines
16 KiB
TypeScript
550 lines
16 KiB
TypeScript
/**
|
|
* @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 };
|