diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index fc4c31b..e74d1d1 100644 Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/src/gemini.rs b/src-tauri/src/gemini.rs index f88e731..26f2cee 100644 --- a/src-tauri/src/gemini.rs +++ b/src-tauri/src/gemini.rs @@ -46,12 +46,19 @@ fn build_safety_settings() -> Value { ]) } -fn build_generation_config(mode: &str) -> Value { +fn build_generation_config( + mode: &str, + aspect_ratio: Option<&str>, + image_size: &str, +) -> Value { let image_config = match mode { - "avatar" => json!({ "aspectRatio": "1:1", "imageSize": "4K" }), - "art" => json!({ "aspectRatio": "16:9", "imageSize": "4K" }), + "avatar" => json!({ "aspectRatio": "1:1", "imageSize": image_size }), + "art" => { + let ratio = aspect_ratio.unwrap_or("16:9"); + json!({ "aspectRatio": ratio, "imageSize": image_size }) + } // replace mode: omit aspectRatio so the model infers it from the source image - _ => json!({ "imageSize": "4K" }), + _ => json!({ "imageSize": image_size }), }; json!({ "imageConfig": image_config, @@ -111,6 +118,8 @@ fn build_user_gemini_parts( pub async fn call_gemini( api_key: String, mode: String, + aspect_ratio: Option, + image_size: String, history: Vec, user_text: Option, user_image_base64: Option, @@ -160,7 +169,11 @@ pub async fn call_gemini( contents.push(json!({"role": "user", "parts": user_parts})); - let generation_config = build_generation_config(mode.as_str()); + let generation_config = build_generation_config( + mode.as_str(), + aspect_ratio.as_deref(), + image_size.as_str(), + ); let safety_settings = build_safety_settings(); let request_body = json!({ @@ -252,8 +265,13 @@ pub async fn call_gemini( let candidates_tokens = usage["candidatesTokenCount"].as_u64().unwrap_or(0); let image_part_count = result_parts.iter().filter(|p| p.part_type == "image").count() as u64; - // Image output tokens (4K = 2000 tokens each) billed at $120/1M tokens - let image_output_tokens = image_part_count * 2_000_u64; + // Image output tokens per image vary by resolution, billed at $120/1M tokens + let tokens_per_image: u64 = match image_size.as_str() { + "1K" => 500, + "2K" => 1_000, + _ => 2_000, // 4K default + }; + let image_output_tokens = image_part_count * tokens_per_image; // Remaining candidates tokens are text/thinking, billed at $12/1M tokens let text_output_tokens = candidates_tokens.saturating_sub(image_output_tokens); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d97c434..92ee690 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -49,13 +49,24 @@ async fn save_config(config: Config) -> Result<(), String> { async fn send_message( api_key: String, mode: String, + aspect_ratio: Option, + image_size: String, history: Vec, user_text: Option, user_image_base64: Option, user_image_mime: Option, ) -> Result { - let (parts, cost_usd) = - call_gemini(api_key, mode, history, user_text, user_image_base64, user_image_mime).await?; + let (parts, cost_usd) = call_gemini( + api_key, + mode, + aspect_ratio, + image_size, + history, + user_text, + user_image_base64, + user_image_mime, + ) + .await?; Ok(SendMessageResult { parts, cost_usd }) } diff --git a/src-tauri/src/storage.rs b/src-tauri/src/storage.rs index a016c7e..208311e 100644 --- a/src-tauri/src/storage.rs +++ b/src-tauri/src/storage.rs @@ -25,11 +25,19 @@ pub struct ThreadMessage { pub cost_usd: Option, } +fn default_image_size() -> String { + "4K".to_string() +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Thread { pub id: String, pub name: String, pub mode: String, + #[serde(rename = "aspectRatio", skip_serializing_if = "Option::is_none")] + pub aspect_ratio: Option, + #[serde(rename = "imageSize", default = "default_image_size")] + pub image_size: String, pub messages: Vec, #[serde(rename = "createdAt")] pub created_at: i64, diff --git a/src/app.tsx b/src/app.tsx index 8afa887..d212f08 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -16,7 +16,14 @@ 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"; +import type { + AspectRatio, + Config, + ImageSize, + Mode, + PendingInput, + Thread, +} from "./types/index.ts"; const generateThreadName = (mode: Mode, text?: string): string => { if ( @@ -120,11 +127,18 @@ const app = (): JSX.Element => { }); const handleNewThread = useCallback( - (mode: Mode): void => { + ( + mode: Mode, + aspectRatio: AspectRatio | undefined, + imageSize: ImageSize, + ): void => { const now = Date.now(); const createdThread: Thread = { + /* eslint-disable-next-line stylistic/no-extra-parens -- Required for conditional spread with exactOptionalPropertyTypes */ + ...(aspectRatio !== undefined && { aspectRatio }), createdAt: now, id: generateId(), + imageSize: imageSize, messages: [], mode: mode, name: `New ${mode} thread`, diff --git a/src/components/inputArea.tsx b/src/components/inputArea.tsx index 3f9faf5..2981870 100644 --- a/src/components/inputArea.tsx +++ b/src/components/inputArea.tsx @@ -19,7 +19,12 @@ import { type JSX, type KeyboardEvent, } from "react"; -import type { Mode, PendingInput } from "../types/index.ts"; +import type { + AspectRatio, + ImageSize, + Mode, + PendingInput, +} from "../types/index.ts"; const dropZoneBaseClass = [ "border-2 border-dashed rounded-xl p-8", @@ -113,14 +118,19 @@ const readClipboardImage = async( onFileRead(new File([ blob ], "clipboard.png", { type: imageType })); }; -const modeLabelText = (mode: Mode): string => { +const modeLabelText = ( + mode: Mode, + aspectRatio: AspectRatio | undefined, + imageSize: ImageSize, +): string => { if (mode === "avatar") { - return "🟣 Avatar Mode (1:1)"; + return `🟣 Avatar Mode · ${imageSize}`; } if (mode === "art") { - return "🩷 Art Mode (16:9)"; + const ratio = aspectRatio ?? "16:9"; + return `🩷 Art Mode (${ratio}) · ${imageSize}`; } - return "🔵 Replace Mode"; + return `🔵 Replace Mode · ${imageSize}`; }; type OnSendCallback = ( @@ -130,7 +140,9 @@ type OnSendCallback = ( )=> void; interface InputAreaProperties { + readonly aspectRatio?: AspectRatio; readonly hasMessages: boolean; + readonly imageSize: ImageSize; readonly initialImageBase64?: string; readonly initialImageMime?: string; readonly initialImagePreview?: string; @@ -144,7 +156,9 @@ interface InputAreaProperties { /** * Renders the input area for composing and sending messages. * @param props - The component props. + * @param props.aspectRatio - The thread's aspect ratio setting (Art mode only). * @param props.hasMessages - Whether the thread already has messages (affects replace mode UI). + * @param props.imageSize - The thread's resolution setting. * @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. @@ -156,7 +170,9 @@ interface InputAreaProperties { * @returns The JSX element. */ const inputArea = ({ + aspectRatio, hasMessages, + imageSize, initialImageBase64, initialImageMime, initialImagePreview, @@ -340,7 +356,7 @@ const inputArea = ({
- {modeLabelText(mode)} + {modeLabelText(mode, aspectRatio, imageSize)}
diff --git a/src/components/newThreadModal.tsx b/src/components/newThreadModal.tsx index 84425b3..3425479 100644 --- a/src/components/newThreadModal.tsx +++ b/src/components/newThreadModal.tsx @@ -1,12 +1,18 @@ /** - * @file Modal for selecting a new thread generation mode. + * @file Modal for selecting a new thread generation mode and options. * @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"; +/* eslint-disable max-lines -- Two-step modal requires many option definitions */ +import { + useCallback, + useState, + type JSX, + type MouseEvent, +} from "react"; +import type { AspectRatio, ImageSize, Mode } from "../types/index.ts"; interface ModeOption { colour: string; @@ -16,14 +22,25 @@ interface ModeOption { mode: Mode; } +interface AspectRatioOption { + label: string; + value: AspectRatio; +} + +interface ImageSizeOption { + description: string; + label: string; + value: ImageSize; +} + 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.", + "Generate landscape or portrait artwork.", + "Choose your aspect ratio and resolution.", ].join(" "); const replaceDescription = [ @@ -55,6 +72,21 @@ const modeOptionList: Array = [ }, ]; +const aspectRatioOptions: Array = [ + { label: "1:1 Square", value: "1:1" }, + { label: "4:3 Standard", value: "4:3" }, + { label: "3:4 Portrait", value: "3:4" }, + { label: "16:9 Widescreen", value: "16:9" }, + { label: "9:16 Wallpaper", value: "9:16" }, + { label: "21:9 Ultrawide", value: "21:9" }, +]; + +const imageSizeOptions: Array = [ + { description: "Faster, cheaper", label: "1K", value: "1K" }, + { description: "Balanced", label: "2K", value: "2K" }, + { description: "Best quality", label: "4K", value: "4K" }, +]; + const colourMap: Record< string, { badge: string; button: string; hover: string } @@ -82,6 +114,20 @@ const isMode = (value: string): value is Mode => { return validModesSet.has(value); }; +const validAspectRatios = new Set([ + "1:1", "3:4", "4:3", "9:16", "16:9", "21:9", +]); + +const isAspectRatio = (value: string): value is AspectRatio => { + return validAspectRatios.has(value); +}; + +const validImageSizes = new Set([ "1K", "2K", "4K" ]); + +const isImageSize = (value: string): value is ImageSize => { + return validImageSizes.has(value); +}; + const overlayClass = [ "fixed inset-0 bg-black/70 backdrop-blur-sm", "flex items-center justify-center z-50", @@ -93,32 +139,243 @@ const panelClass = [ "shadow-2xl shadow-purple-900/30", ].join(" "); +const pillBaseClass = [ + "px-4 py-2 rounded-xl border text-sm font-medium", + "transition-all duration-200 cursor-pointer", +].join(" "); + +const pillActiveClass = "border-purple-500 bg-purple-600/30 text-white"; + +const pillInactiveClass = [ + "border-purple-900/40 bg-transparent text-gray-400", + "hover:border-purple-500/60 hover:text-gray-200", +].join(" "); + +const startButtonClass = [ + "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-3 rounded-xl", + "transition-all duration-200 mt-2", +].join(" "); + +const sizeButtonActiveClass = "border-purple-500 bg-purple-600/30 text-white"; + +const sizeButtonInactiveClass = [ + "border-purple-900/40 bg-transparent text-gray-400", + "hover:border-purple-500/60 hover:text-gray-200", +].join(" "); + +const backButtonClass = [ + "text-gray-400 hover:text-white", + "transition-colors text-xl", +].join(" "); + +const sectionLabelClass = [ + "text-sm text-gray-400", + "uppercase tracking-wider mb-3", +].join(" "); + interface NewThreadModalProperties { readonly onClose: ()=> void; - readonly onSelect: (mode: Mode)=> void; + readonly onSelect: ( + mode: Mode, + aspectRatio: AspectRatio | undefined, + imageSize: ImageSize, + )=> void; } /** - * Renders the new thread modal for selecting a generation mode. + * Renders the new thread modal for selecting a generation mode and options. * @param props - The component props. * @param props.onClose - Callback to close the modal. - * @param props.onSelect - Callback when a mode is selected. + * @param props.onSelect - Callback when a mode and options are confirmed. * @returns The JSX element. */ const threadModal = ({ onClose, onSelect, }: NewThreadModalProperties): JSX.Element => { + const [ step, setStep ] = useState<"mode" | "options">("mode"); + const [ selectedMode, setSelectedMode ] = useState( + undefined, + ); + const [ aspectRatio, setAspectRatio ] = useState("16:9"); + const [ imageSize, setImageSize ] = useState("4K"); + const handleModeSelect = useCallback( (event: MouseEvent): void => { const rawMode = event.currentTarget.dataset.mode; if (rawMode !== undefined && isMode(rawMode)) { - onSelect(rawMode); + setSelectedMode(rawMode); + setStep("options"); } }, - [ onSelect ], + [], ); + const handleBack = useCallback((): void => { + setStep("mode"); + }, []); + + const handleStart = useCallback((): void => { + if (selectedMode === undefined) { + return; + } + const ratio = selectedMode === "art" + ? aspectRatio + : undefined; + onSelect(selectedMode, ratio, imageSize); + }, [ selectedMode, aspectRatio, imageSize, onSelect ]); + + const handleAspectRatioSelect = useCallback( + (event: MouseEvent): void => { + const rawRatio = event.currentTarget.dataset.ratio; + if (rawRatio !== undefined && isAspectRatio(rawRatio)) { + setAspectRatio(rawRatio); + } + }, + [], + ); + + const handleImageSizeSelect = useCallback( + (event: MouseEvent): void => { + const rawSize = event.currentTarget.dataset.size; + if (rawSize !== undefined && isImageSize(rawSize)) { + setImageSize(rawSize); + } + }, + [], + ); + + const selectedModeOption = modeOptionList.find((o) => { + return o.mode === selectedMode; + }); + const selectedColours = selectedModeOption === undefined + ? colourMap.purple + : colourMap[selectedModeOption.colour]; + + if (step === "options" && selectedModeOption !== undefined) { + return ( +
+
+
+ +

+ {"Configure Thread"} +

+ +
+ +
+ {selectedModeOption.icon} +
+
+ + {selectedModeOption.label} + + + {selectedModeOption.mode} + +
+

+ {selectedModeOption.description} +

+
+
+ + {selectedMode === "art" + ?
+

+ {"Aspect Ratio"} +

+
+ {aspectRatioOptions.map((option) => { + const pillClass = [ + pillBaseClass, + aspectRatio === option.value + ? pillActiveClass + : pillInactiveClass, + ].join(" "); + return ( + + ); + })} +
+
+ + : null} + +
+

+ {"Resolution"} +

+
+ {imageSizeOptions.map((option) => { + const sizeClass = [ + "flex-1 flex flex-col items-center py-3 px-2", + "rounded-xl border text-sm font-medium", + "transition-all duration-200 cursor-pointer", + imageSize === option.value + ? sizeButtonActiveClass + : sizeButtonInactiveClass, + ].join(" "); + return ( + + ); + })} +
+
+ + +
+
+ ); + } + return (
diff --git a/src/components/threadView.tsx b/src/components/threadView.tsx index 5cc30b0..9ee32d7 100644 --- a/src/components/threadView.tsx +++ b/src/components/threadView.tsx @@ -68,19 +68,19 @@ const modeLabel = (mode: Mode): string => { interface BuildUserPartsOptions { readonly imageBase64?: string; readonly imageMime?: string; - readonly mode: Mode; readonly text: string; } const buildUserParts = ({ imageBase64, imageMime, - mode, text, }: BuildUserPartsOptions): Array => { const userParts: Array = []; - if (mode === "replace" && imageBase64 !== undefined) { + if (imageBase64 === undefined) { + userParts.push({ text: text, type: "text" }); + } else { userParts.push({ imageData: imageBase64, mimeType: imageMime ?? "image/png", @@ -89,8 +89,6 @@ const buildUserParts = ({ if (text.length > 0) { userParts.push({ text: text, type: "text" }); } - } else { - userParts.push({ text: text, type: "text" }); } return userParts; @@ -174,13 +172,18 @@ const threadView = ({ onLoadingChange(true); onErrorChange(undefined); - const { messages, mode } = thread; + const { + aspectRatio, + imageSize: rawImageSize, + messages, + mode, + } = thread; + const imageSize = rawImageSize ?? "4K"; 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, }); @@ -217,7 +220,9 @@ const threadView = ({ "send_message", { apiKey, + aspectRatio, history, + imageSize, mode, userImageBase64, userImageMime, @@ -253,7 +258,13 @@ const threadView = ({ const handleRetry = useCallback( (modelMessageIndex: number): void => { - const { messages, mode } = thread; + const { + aspectRatio, + imageSize: rawImageSize, + messages, + mode, + } = thread; + const imageSize = rawImageSize ?? "4K"; const userMessageIndex = modelMessageIndex - 1; const userMessage = messages[userMessageIndex]; @@ -300,7 +311,9 @@ const threadView = ({ } const response = await invoke("send_message", { apiKey, + aspectRatio, history, + imageSize, mode, userImageBase64, userImageMime, @@ -353,7 +366,13 @@ const threadView = ({ const handleEditCommit = useCallback( (messageIndex: number, editedText: string): void => { - const { messages, mode } = thread; + const { + aspectRatio, + imageSize: rawImageSize, + messages, + mode, + } = thread; + const imageSize = rawImageSize ?? "4K"; const originalMessage = messages[messageIndex]; if (originalMessage === undefined) { return; @@ -404,7 +423,9 @@ const threadView = ({ } const response = await invoke("send_message", { apiKey, + aspectRatio, history, + imageSize, mode, userImageBase64, userImageMime, @@ -536,7 +557,11 @@ const threadView = ({ {...(pendingInput?.text !== undefined && { initialText: pendingInput.text, })} + {...(thread.aspectRatio !== undefined && { + aspectRatio: thread.aspectRatio, + })} hasMessages={thread.messages.length > 0} + imageSize={thread.imageSize ?? "4K"} isLoading={isLoading} mode={thread.mode} onInputChange={onPendingInputChange} diff --git a/src/types/index.ts b/src/types/index.ts index 9c824a9..a93af5d 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -5,6 +5,8 @@ * @author Naomi Carrigan */ +type AspectRatio = "1:1" | "3:4" | "4:3" | "9:16" | "16:9" | "21:9"; +type ImageSize = "1K" | "2K" | "4K"; type Mode = "avatar" | "art" | "replace"; interface Config { @@ -33,12 +35,23 @@ interface ThreadMessage { } interface Thread { - createdAt: number; - id: string; - messages: Array; - mode: Mode; - name: string; - updatedAt: number; + aspectRatio?: AspectRatio; + createdAt: number; + id: string; + imageSize?: ImageSize; + messages: Array; + mode: Mode; + name: string; + updatedAt: number; } -export type { Config, MessagePart, Mode, PendingInput, Thread, ThreadMessage }; +export type { + AspectRatio, + Config, + ImageSize, + MessagePart, + Mode, + PendingInput, + Thread, + ThreadMessage, +};