generated from nhcarrigan/template
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.
This commit is contained in:
+365
@@ -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 };
|
||||
Reference in New Issue
Block a user