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 83 additions and 67 deletions
+1 -1
View File
@@ -25,7 +25,7 @@
"@types/react-dom": "19.1.2", "@types/react-dom": "19.1.2",
"autoprefixer": "10.4.21", "autoprefixer": "10.4.21",
"eslint": "9.25.1", "eslint": "9.25.1",
"postcss": "8.5.8", "postcss": "8.5.3",
"tailwindcss": "3.4.17", "tailwindcss": "3.4.17",
"typescript": "5.8.3", "typescript": "5.8.3",
"vite": "6.3.2", "vite": "6.3.2",
+22 -22
View File
@@ -44,13 +44,13 @@ importers:
version: 4.4.1(vite@6.3.2(jiti@1.21.7)(yaml@2.8.3)) version: 4.4.1(vite@6.3.2(jiti@1.21.7)(yaml@2.8.3))
autoprefixer: autoprefixer:
specifier: 10.4.21 specifier: 10.4.21
version: 10.4.21(postcss@8.5.8) version: 10.4.21(postcss@8.5.3)
eslint: eslint:
specifier: 9.25.1 specifier: 9.25.1
version: 9.25.1(jiti@1.21.7) version: 9.25.1(jiti@1.21.7)
postcss: postcss:
specifier: 8.5.8 specifier: 8.5.3
version: 8.5.8 version: 8.5.3
tailwindcss: tailwindcss:
specifier: 3.4.17 specifier: 3.4.17
version: 3.4.17 version: 3.4.17
@@ -1930,8 +1930,8 @@ packages:
postcss-value-parser@4.2.0: postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
postcss@8.5.8: postcss@8.5.3:
resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==}
engines: {node: ^10 || ^12 || >=14} engines: {node: ^10 || ^12 || >=14}
prelude-ls@1.2.1: prelude-ls@1.2.1:
@@ -3283,14 +3283,14 @@ snapshots:
async-function@1.0.0: {} async-function@1.0.0: {}
autoprefixer@10.4.21(postcss@8.5.8): autoprefixer@10.4.21(postcss@8.5.3):
dependencies: dependencies:
browserslist: 4.28.2 browserslist: 4.28.2
caniuse-lite: 1.0.30001787 caniuse-lite: 1.0.30001787
fraction.js: 4.3.7 fraction.js: 4.3.7
normalize-range: 0.1.2 normalize-range: 0.1.2
picocolors: 1.1.1 picocolors: 1.1.1
postcss: 8.5.8 postcss: 8.5.3
postcss-value-parser: 4.2.0 postcss-value-parser: 4.2.0
available-typed-arrays@1.0.7: available-typed-arrays@1.0.7:
@@ -4386,28 +4386,28 @@ snapshots:
possible-typed-array-names@1.1.0: {} possible-typed-array-names@1.1.0: {}
postcss-import@15.1.0(postcss@8.5.8): postcss-import@15.1.0(postcss@8.5.3):
dependencies: dependencies:
postcss: 8.5.8 postcss: 8.5.3
postcss-value-parser: 4.2.0 postcss-value-parser: 4.2.0
read-cache: 1.0.0 read-cache: 1.0.0
resolve: 1.22.11 resolve: 1.22.11
postcss-js@4.1.0(postcss@8.5.8): postcss-js@4.1.0(postcss@8.5.3):
dependencies: dependencies:
camelcase-css: 2.0.1 camelcase-css: 2.0.1
postcss: 8.5.8 postcss: 8.5.3
postcss-load-config@4.0.2(postcss@8.5.8): postcss-load-config@4.0.2(postcss@8.5.3):
dependencies: dependencies:
lilconfig: 3.1.3 lilconfig: 3.1.3
yaml: 2.8.3 yaml: 2.8.3
optionalDependencies: optionalDependencies:
postcss: 8.5.8 postcss: 8.5.3
postcss-nested@6.2.0(postcss@8.5.8): postcss-nested@6.2.0(postcss@8.5.3):
dependencies: dependencies:
postcss: 8.5.8 postcss: 8.5.3
postcss-selector-parser: 6.1.2 postcss-selector-parser: 6.1.2
postcss-selector-parser@6.1.2: postcss-selector-parser@6.1.2:
@@ -4417,7 +4417,7 @@ snapshots:
postcss-value-parser@4.2.0: {} postcss-value-parser@4.2.0: {}
postcss@8.5.8: postcss@8.5.3:
dependencies: dependencies:
nanoid: 3.3.11 nanoid: 3.3.11
picocolors: 1.1.1 picocolors: 1.1.1
@@ -4757,11 +4757,11 @@ snapshots:
normalize-path: 3.0.0 normalize-path: 3.0.0
object-hash: 3.0.0 object-hash: 3.0.0
picocolors: 1.1.1 picocolors: 1.1.1
postcss: 8.5.8 postcss: 8.5.3
postcss-import: 15.1.0(postcss@8.5.8) postcss-import: 15.1.0(postcss@8.5.3)
postcss-js: 4.1.0(postcss@8.5.8) postcss-js: 4.1.0(postcss@8.5.3)
postcss-load-config: 4.0.2(postcss@8.5.8) postcss-load-config: 4.0.2(postcss@8.5.3)
postcss-nested: 6.2.0(postcss@8.5.8) postcss-nested: 6.2.0(postcss@8.5.3)
postcss-selector-parser: 6.1.2 postcss-selector-parser: 6.1.2
resolve: 1.22.11 resolve: 1.22.11
sucrase: 3.35.1 sucrase: 3.35.1
@@ -4884,7 +4884,7 @@ snapshots:
esbuild: 0.25.12 esbuild: 0.25.12
fdir: 6.5.0(picomatch@4.0.4) fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4 picomatch: 4.0.4
postcss: 8.5.8 postcss: 8.5.3
rollup: 4.60.1 rollup: 4.60.1
tinyglobby: 0.2.16 tinyglobby: 0.2.16
optionalDependencies: optionalDependencies:
+14 -10
View File
@@ -83,22 +83,26 @@ fn build_user_gemini_parts(
user_image_base64: &Option<String>, user_image_base64: &Option<String>,
user_image_mime: &Option<String>, user_image_mime: &Option<String>,
) -> Vec<Value> { ) -> 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 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 base_text = user_text.as_deref().unwrap_or("");
let final_text = if base_text.is_empty() { let final_text = if mode == "replace" {
REPLACE_MODE_APPEND.to_string() if base_text.is_empty() {
REPLACE_MODE_APPEND.to_string()
} else {
format!("{}\n{}", base_text, REPLACE_MODE_APPEND)
}
} else { } else {
format!("{}\n{}", base_text, REPLACE_MODE_APPEND) base_text.to_string()
}; };
vec![ let mut parts = vec![json!({"inlineData": {"mimeType": mime, "data": image_data}})];
json!({"inlineData": {"mimeType": mime, "data": data}}), if !final_text.is_empty() {
json!({"text": final_text}), parts.push(json!({"text": final_text}));
] }
parts
} else { } 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(""); let text = user_text.as_deref().unwrap_or("");
vec![json!({"text": text})] vec![json!({"text": text})]
} }
+46 -34
View File
@@ -256,7 +256,8 @@ const inputArea = ({
if (isInitialReplace && imageBase64 === undefined) { if (isInitialReplace && imageBase64 === undefined) {
return; return;
} }
if (!isInitialReplace && text.trim().length === 0) { const hasContent = text.trim().length > 0 || imageBase64 !== undefined;
if (!isInitialReplace && !hasContent) {
return; return;
} }
@@ -332,6 +333,9 @@ const inputArea = ({
: dropZoneInactiveClass, : dropZoneInactiveClass,
].join(" "); ].join(" ");
const hasNoContent = text.trim().length === 0 && imageBase64 === undefined;
const isSendDisabled = isLoading || hasNoContent;
return ( return (
<div className="border-t border-purple-900/30 p-4 bg-[#0f0a1a]"> <div className="border-t border-purple-900/30 p-4 bg-[#0f0a1a]">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
@@ -397,6 +401,15 @@ const inputArea = ({
type="file" type="file"
/> />
<textarea
className={textareaClass}
disabled={isLoading}
onChange={handleTextChange}
placeholder="Add notes to include with the image (optional)..."
rows={2}
value={text}
/>
<button <button
className={replaceButtonClass} className={replaceButtonClass}
disabled={isLoading || imageBase64 === undefined} disabled={isLoading || imageBase64 === undefined}
@@ -414,41 +427,40 @@ const inputArea = ({
</div> </div>
: <div className="flex flex-col gap-3"> : <div className="flex flex-col gap-3">
{mode === "replace" <div className="flex flex-col gap-2">
? <div className="flex flex-col gap-2"> {imagePreview === undefined
{imagePreview === undefined ? <button
? <button className={pasteButtonClass}
className={pasteButtonClass} onClick={handlePasteButtonClick}
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" type="button"
> >
{"📋 Paste replacement image (optional)"} {"×"}
</button> </button>
</div>}
: <div className="relative inline-block"> <input
<img accept="image/*"
alt="Upload preview" className="hidden"
className="max-h-32 rounded-lg border border-purple-700/40" onChange={handleFileChange}
src={imagePreview} ref={fileInputReference}
/> type="file"
<button />
className={clearButtonClass} </div>
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"> <div className="flex gap-3 items-end">
<textarea <textarea
@@ -464,7 +476,7 @@ const inputArea = ({
/> />
<button <button
className={sendButtonClass} className={sendButtonClass}
disabled={isLoading || text.trim().length === 0} disabled={isSendDisabled}
onClick={handleSend} onClick={handleSend}
type="button" type="button"
> >