Compare commits

..

1 Commits

Author SHA1 Message Date
hikari 10e013a983 feat: add image input to art/avatar modes and notes to replace mode
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m15s
CI / Lint & Check (pull_request) Successful in 12m57s
CI / Build Windows (pull_request) Failing after 21m39s
- Art and Avatar modes now support optional image uploads via paste or
  file picker, sent to Gemini alongside the text prompt
- Initial Replace mode gains an optional textarea for notes to include
  with the uploaded image
- Backend updated to send user images for all modes, appending replace
  instructions only when in replace mode
- Send validation updated to allow image-only messages in art/avatar
2026-04-13 09:18:20 -07:00
4 changed files with 71 additions and 55 deletions
+1 -1
View File
@@ -15,7 +15,7 @@
"@tauri-apps/plugin-dialog": "2.3.0",
"@tauri-apps/plugin-fs": "2.4.0",
"react": "19.1.0",
"react-dom": "19.2.4"
"react-dom": "19.1.0"
},
"devDependencies": {
"@nhcarrigan/eslint-config": "5.2.0",
+10 -10
View File
@@ -21,8 +21,8 @@ importers:
specifier: 19.1.0
version: 19.1.0
react-dom:
specifier: 19.2.4
version: 19.2.4(react@19.1.0)
specifier: 19.1.0
version: 19.1.0(react@19.1.0)
devDependencies:
'@nhcarrigan/eslint-config':
specifier: 5.2.0
@@ -1948,10 +1948,10 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
react-dom@19.2.4:
resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
react-dom@19.1.0:
resolution: {integrity: sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==}
peerDependencies:
react: ^19.2.4
react: ^19.1.0
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@@ -2037,8 +2037,8 @@ packages:
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
engines: {node: '>= 0.4'}
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
scheduler@0.26.0:
resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==}
semver@5.7.2:
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
@@ -4435,10 +4435,10 @@ snapshots:
queue-microtask@1.2.3: {}
react-dom@19.2.4(react@19.1.0):
react-dom@19.1.0(react@19.1.0):
dependencies:
react: 19.1.0
scheduler: 0.27.0
scheduler: 0.26.0
react-is@16.13.1: {}
@@ -4568,7 +4568,7 @@ snapshots:
es-errors: 1.3.0
is-regex: 1.2.1
scheduler@0.27.0: {}
scheduler@0.26.0: {}
semver@5.7.2: {}
+14 -10
View File
@@ -83,22 +83,26 @@ fn build_user_gemini_parts(
user_image_base64: &Option<String>,
user_image_mime: &Option<String>,
) -> Vec<Value> {
if mode == "replace" && user_image_base64.is_some() {
if let Some(image_data) = user_image_base64.as_deref() {
let mime = user_image_mime.as_deref().unwrap_or("image/png");
let data = user_image_base64.as_deref().unwrap_or("");
let base_text = user_text.as_deref().unwrap_or("");
let final_text = if base_text.is_empty() {
REPLACE_MODE_APPEND.to_string()
let final_text = if mode == "replace" {
if base_text.is_empty() {
REPLACE_MODE_APPEND.to_string()
} else {
format!("{}\n{}", base_text, REPLACE_MODE_APPEND)
}
} else {
format!("{}\n{}", base_text, REPLACE_MODE_APPEND)
base_text.to_string()
};
vec![
json!({"inlineData": {"mimeType": mime, "data": data}}),
json!({"text": final_text}),
]
let mut parts = vec![json!({"inlineData": {"mimeType": mime, "data": image_data}})];
if !final_text.is_empty() {
parts.push(json!({"text": final_text}));
}
parts
} else {
// Art/avatar mode, or replace mode follow-up correction (text only)
// No image: text-only message
let text = user_text.as_deref().unwrap_or("");
vec![json!({"text": text})]
}
+46 -34
View File
@@ -256,7 +256,8 @@ const inputArea = ({
if (isInitialReplace && imageBase64 === undefined) {
return;
}
if (!isInitialReplace && text.trim().length === 0) {
const hasContent = text.trim().length > 0 || imageBase64 !== undefined;
if (!isInitialReplace && !hasContent) {
return;
}
@@ -332,6 +333,9 @@ const inputArea = ({
: dropZoneInactiveClass,
].join(" ");
const hasNoContent = text.trim().length === 0 && imageBase64 === undefined;
const isSendDisabled = isLoading || hasNoContent;
return (
<div className="border-t border-purple-900/30 p-4 bg-[#0f0a1a]">
<div className="flex items-center gap-2 mb-2">
@@ -397,6 +401,15 @@ const inputArea = ({
type="file"
/>
<textarea
className={textareaClass}
disabled={isLoading}
onChange={handleTextChange}
placeholder="Add notes to include with the image (optional)..."
rows={2}
value={text}
/>
<button
className={replaceButtonClass}
disabled={isLoading || imageBase64 === undefined}
@@ -414,41 +427,40 @@ const inputArea = ({
</div>
: <div className="flex flex-col gap-3">
{mode === "replace"
? <div className="flex flex-col gap-2">
{imagePreview === undefined
? <button
className={pasteButtonClass}
onClick={handlePasteButtonClick}
<div className="flex flex-col gap-2">
{imagePreview === undefined
? <button
className={pasteButtonClass}
onClick={handlePasteButtonClick}
type="button"
>
{mode === "replace"
? "๐Ÿ“‹ Paste replacement image (optional)"
: "๐Ÿ“‹ Paste 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"
>
{"๐Ÿ“‹ 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>}
<input
accept="image/*"
className="hidden"
onChange={handleFileChange}
ref={fileInputReference}
type="file"
/>
</div>
<div className="flex gap-3 items-end">
<textarea
@@ -464,7 +476,7 @@ const inputArea = ({
/>
<button
className={sendButtonClass}
disabled={isLoading || text.trim().length === 0}
disabled={isSendDisabled}
onClick={handleSend}
type="button"
>