quick styles and actions | store
BIN
packages/kbot/dist/win-64/tauri-app.exe
vendored
BIN
packages/kbot/generated_gen_1.png
Normal file
|
After Width: | Height: | Size: 1006 KiB |
BIN
packages/kbot/generated_gen_2.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
packages/kbot/generated_gen_3.png
Normal file
|
After Width: | Height: | Size: 1021 KiB |
BIN
packages/kbot/generated_gen_4.png
Normal file
|
After Width: | Height: | Size: 972 KiB |
BIN
packages/kbot/generated_gen_5.png
Normal file
|
After Width: | Height: | Size: 1.9 MiB |
BIN
packages/kbot/generated_gen_6.png
Normal file
|
After Width: | Height: | Size: 1.7 MiB |
BIN
packages/kbot/generated_gen_7.png
Normal file
|
After Width: | Height: | Size: 1.8 MiB |
@ -14,6 +14,20 @@
|
||||
},
|
||||
"core:default",
|
||||
"fs:default",
|
||||
"fs:allow-write-text-file",
|
||||
"fs:allow-read-text-file",
|
||||
"fs:allow-create",
|
||||
"fs:allow-mkdir",
|
||||
"fs:allow-exists",
|
||||
"fs:allow-write",
|
||||
"fs:allow-read",
|
||||
{
|
||||
"identifier": "fs:scope-appdata-recursive",
|
||||
"allow": [
|
||||
{ "path": "$APPDATA/" },
|
||||
{ "path": "$APPDATA/**" }
|
||||
]
|
||||
},
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-maximize",
|
||||
"core:window:allow-unmaximize",
|
||||
|
||||
@ -3,6 +3,7 @@ import { ImageFile, PromptTemplate } from "./types";
|
||||
import { useTauriListeners } from "./hooks/useTauriListeners";
|
||||
import { tauriApi } from "./lib/tauriApi";
|
||||
import { saveStore } from "./lib/init";
|
||||
import { QUICK_STYLES, QUICK_ACTIONS } from "./constants";
|
||||
import log from "./lib/log";
|
||||
import Header from "./components/Header";
|
||||
import PromptForm from "./components/PromptForm";
|
||||
@ -44,6 +45,38 @@ function App() {
|
||||
const [prompts, setPrompts] = useState<PromptTemplate[]>([]);
|
||||
|
||||
|
||||
const appendStyle = (style: string) => {
|
||||
setPrompt(prev => {
|
||||
const trimmed = prev.trim();
|
||||
if (trimmed) {
|
||||
return `${trimmed}, ${style}`;
|
||||
} else {
|
||||
return style;
|
||||
}
|
||||
});
|
||||
log.info(`🎨 Style applied: ${style}`);
|
||||
};
|
||||
|
||||
const executeQuickAction = async (action: { name: string; prompt: string; icon: string }) => {
|
||||
// Find the last non-generated image to use as input
|
||||
const inputImages = files.filter(f => !f.isGenerated);
|
||||
if (inputImages.length === 0) {
|
||||
log.warn('No input images available for quick action');
|
||||
return;
|
||||
}
|
||||
|
||||
const lastImage = inputImages[inputImages.length - 1];
|
||||
|
||||
// Select the last image
|
||||
handleImageSelection(lastImage.path, false);
|
||||
|
||||
// Set the action prompt
|
||||
setPrompt(action.prompt);
|
||||
|
||||
// Generate with the selected image
|
||||
log.info(`🚀 Executing quick action: ${action.name}`);
|
||||
await generateImage(action.prompt, [lastImage]);
|
||||
};
|
||||
|
||||
const importPrompts = async () => {
|
||||
try {
|
||||
@ -581,6 +614,10 @@ function App() {
|
||||
savePrompts={savePrompts}
|
||||
importPrompts={importPrompts}
|
||||
exportPrompts={exportPrompts}
|
||||
quickStyles={QUICK_STYLES}
|
||||
appendStyle={appendStyle}
|
||||
quickActions={QUICK_ACTIONS}
|
||||
executeQuickAction={executeQuickAction}
|
||||
/>
|
||||
|
||||
{/* Debug Panel */}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { ImageFile, PromptTemplate } from '../types';
|
||||
import { QUICK_ACTIONS } from '../constants';
|
||||
import ImageGallery from './ImageGallery';
|
||||
import { useDropZone } from '../hooks/useDropZone';
|
||||
import PromptManager from './PromptManager';
|
||||
@ -30,6 +31,10 @@ interface PromptFormProps {
|
||||
savePrompts: (prompts: PromptTemplate[]) => void;
|
||||
importPrompts: () => void;
|
||||
exportPrompts: () => void;
|
||||
quickStyles: readonly string[];
|
||||
appendStyle: (style: string) => void;
|
||||
quickActions: typeof QUICK_ACTIONS;
|
||||
executeQuickAction: (action: { name: string; prompt: string; icon: string }) => Promise<void>;
|
||||
}
|
||||
|
||||
const PromptForm: React.FC<PromptFormProps> = ({
|
||||
@ -58,6 +63,10 @@ const PromptForm: React.FC<PromptFormProps> = ({
|
||||
savePrompts,
|
||||
importPrompts,
|
||||
exportPrompts,
|
||||
quickStyles,
|
||||
appendStyle,
|
||||
quickActions,
|
||||
executeQuickAction,
|
||||
}) => {
|
||||
const selectedCount = getSelectedImages().length;
|
||||
const { ref: dropZoneRef, dragIn } = useDropZone({ onDrop: addFiles });
|
||||
@ -89,6 +98,71 @@ const PromptForm: React.FC<PromptFormProps> = ({
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Quick Style Picker */}
|
||||
<div className="mt-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="text-xs font-medium text-slate-600 dark:text-slate-400 self-center mr-2">Quick styles:</span>
|
||||
{quickStyles.map((style) => (
|
||||
<button
|
||||
key={style}
|
||||
type="button"
|
||||
onClick={() => appendStyle(style)}
|
||||
className="text-xs px-3 py-1 rounded-full bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 transition-colors duration-200 border border-slate-200 dark:border-slate-700"
|
||||
>
|
||||
{style}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="mt-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="text-xs font-medium text-slate-600 dark:text-slate-400 self-center mr-2">Quick actions:</span>
|
||||
{quickActions.map((action) => {
|
||||
const hasInputImages = files.filter(f => !f.isGenerated).length > 0;
|
||||
return (
|
||||
<button
|
||||
key={action.name}
|
||||
type="button"
|
||||
onClick={() => executeQuickAction(action)}
|
||||
disabled={isGenerating || !hasInputImages}
|
||||
className={`text-xs px-3 py-1 rounded-full transition-colors duration-200 border ${
|
||||
!hasInputImages
|
||||
? 'bg-slate-50 text-slate-400 border-slate-200 dark:bg-slate-800 dark:text-slate-500 dark:border-slate-700 cursor-not-allowed'
|
||||
: 'bg-blue-100 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800 text-blue-700 dark:text-blue-300 border-blue-200 dark:border-blue-700'
|
||||
} ${isGenerating ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
title={!hasInputImages ? 'Add an image first to use quick actions' : `Apply ${action.name} to the last image`}
|
||||
>
|
||||
{action.icon} {action.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Generate Button */}
|
||||
<div className="flex gap-3 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={submit}
|
||||
disabled={isGenerating || !prompt.trim()}
|
||||
className="flex-1 glass-button bg-gradient-to-r from-indigo-500 to-purple-600 hover:from-indigo-600 hover:to-purple-700 text-white px-6 py-3 rounded-xl font-semibold disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-300"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Generating...
|
||||
</span>
|
||||
) : (
|
||||
'🎨 Generate Image'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<PromptManager
|
||||
@ -194,27 +268,6 @@ const PromptForm: React.FC<PromptFormProps> = ({
|
||||
)}
|
||||
|
||||
<div className="w-full mt-8 space-y-3">
|
||||
<button
|
||||
type="submit"
|
||||
className={`w-full font-bold py-4 px-8 rounded-xl transition-all duration-300 shadow-lg ${
|
||||
isGenerating
|
||||
? 'bg-slate-400 dark:bg-slate-600 cursor-not-allowed'
|
||||
: 'status-gradient-connected hover:shadow-xl hover:scale-[1.02]'
|
||||
}`}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<div className="w-5 h-5 border-2 border-current/30 border-t-current rounded-full animate-spin"></div>
|
||||
<span>Generating...</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
✨ Generate Image
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{files.some(file => file.path.startsWith('generated_')) && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@ -17,3 +17,43 @@ export enum TauriEvent {
|
||||
FILE_DELETED_SUCCESSFULLY = 'file-deleted-successfully',
|
||||
FILE_DELETION_ERROR = 'file-deletion-error',
|
||||
}
|
||||
|
||||
export const QUICK_STYLES = [
|
||||
'cartoon style',
|
||||
'photorealistic',
|
||||
'noir style',
|
||||
'product mockup',
|
||||
'watercolor painting',
|
||||
'digital art',
|
||||
'minimalist design',
|
||||
'vintage poster',
|
||||
'cyberpunk aesthetic'
|
||||
] as const;
|
||||
|
||||
export const QUICK_ACTIONS = [
|
||||
{
|
||||
name: 'Remove Background',
|
||||
prompt: 'remove the background, make it transparent',
|
||||
icon: '🔲'
|
||||
},
|
||||
{
|
||||
name: 'Cleanup',
|
||||
prompt: 'clean up and enhance the image, remove noise and artifacts',
|
||||
icon: '✨'
|
||||
},
|
||||
{
|
||||
name: 'Crop Foreground',
|
||||
prompt: 'crop to focus on the main subject, remove unnecessary background',
|
||||
icon: '✂️'
|
||||
},
|
||||
{
|
||||
name: 'Improve Colors',
|
||||
prompt: 'enhance colors, improve saturation and contrast',
|
||||
icon: '🎨'
|
||||
},
|
||||
{
|
||||
name: 'Product Presentation',
|
||||
prompt: 'professional product photography style, clean background, studio lighting',
|
||||
icon: '📦'
|
||||
}
|
||||
] as const;
|
||||
@ -83,10 +83,12 @@ export function useTauriListeners({
|
||||
const isGeneratedImage = isGenerating || document.querySelector('[src^="data:image/svg+xml"]');
|
||||
|
||||
if (isGeneratedImage) {
|
||||
const generatedImageFile: ImageFile = { path: imageData.filename, src, isGenerated: true };
|
||||
const generatedImageFile: ImageFile = { path: imageData.filename, src, isGenerated: true, selected: true };
|
||||
setFiles(prev => {
|
||||
const withoutPlaceholder = prev.filter(file => !file.path.startsWith('generating_') && file.path !== imageData.filename);
|
||||
return [...withoutPlaceholder, generatedImageFile];
|
||||
// Deselect all other images and select the new generated one
|
||||
const updatedFiles = withoutPlaceholder.map(file => ({ ...file, selected: false }));
|
||||
return [...updatedFiles, generatedImageFile];
|
||||
});
|
||||
|
||||
if (generationTimeoutId) {
|
||||
|
||||
@ -113,13 +113,42 @@ export async function loadStore(callbacks: Pick<InitCallbacks, 'setPrompts'>): P
|
||||
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
log.info(`📂 Prompt store not found or failed to load. A new one will be created on save.`, {
|
||||
log.info(`📂 Prompt store not found, creating initial store...`, {
|
||||
error: err.message,
|
||||
errorName: err.name,
|
||||
storePath: STORE_FILE_NAME
|
||||
});
|
||||
|
||||
// Create initial empty store
|
||||
try {
|
||||
const initialPrompts: PromptTemplate[] = [];
|
||||
const configDir = await tauriApi.path.appDataDir();
|
||||
const storePath = await tauriApi.path.join(configDir, STORE_FILE_NAME);
|
||||
|
||||
log.debug(`📁 Ensuring directory exists: ${configDir}`);
|
||||
|
||||
// Tauri should create the APPDATA directory automatically, but let's verify
|
||||
log.debug(`📁 APPDATA directory should exist: ${configDir}`);
|
||||
|
||||
const initialData = JSON.stringify({ prompts: initialPrompts }, null, 2);
|
||||
log.debug(`💾 Writing initial store data to: ${storePath}`);
|
||||
|
||||
await tauriApi.fs.writeTextFile(storePath, initialData);
|
||||
log.info(`✅ Initial store created successfully at ${storePath}`);
|
||||
|
||||
setPrompts(initialPrompts);
|
||||
return initialPrompts;
|
||||
} catch (createError) {
|
||||
const configDir = await tauriApi.path.appDataDir().catch(() => 'unknown');
|
||||
log.error('Failed to create initial store - directory may not exist', {
|
||||
error: (createError as Error).message,
|
||||
errorName: (createError as Error).name,
|
||||
storePath: STORE_FILE_NAME,
|
||||
configDir,
|
||||
note: 'You may need to manually create the APPDATA directory first'
|
||||
});
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||