Add generative panels, provider settings UI, and credits
Frontend: - tools/ai_replace_selection.js: use any selection → remote inpaint with prompt - modules/generate/text_to_image.js: Text → Image dialog (new layer or replace canvas) - modules/generate/outpaint.js: Expand Canvas in any direction via remote provider - modules/tools/ai_provider_settings.js: in-app provider config (OpenAI / InvokeAI / ComfyUI / Replicate); persists to localStorage, pushes to POST /api/config at runtime - config.js: register ai_replace_selection tool - config-menu.js: add Generate menu (Text→Image, Outpaint); AI Provider Settings under Tools - modules/help/about.js: updated credits (LaMa, rembg, SAM, InvokeAI, ComfyUI, OpenAI) - api/capabilities.js: add refreshCapabilities() for post-save cache invalidation Backend: - routers/ai_tools.py: POST /api/config — apply provider settings at runtime without restart (session-scoped, non-persistent; .env for permanence) https://claude.ai/code/session_01B58MaJCU1R6KwBDJCp8AfN
This commit is contained in:
@@ -244,6 +244,49 @@ async def outpaint(req: OutpaintRequest):
|
||||
|
||||
# ─── Config / capabilities ────────────────────────────────────────────────────
|
||||
|
||||
class ConfigUpdateRequest(BaseModel):
|
||||
ai_provider: Optional[str] = None
|
||||
openai_api_key: Optional[str] = None
|
||||
openai_model: Optional[str] = None
|
||||
invokeai_url: Optional[str] = None
|
||||
invokeai_default_model: Optional[str] = None
|
||||
comfyui_url: Optional[str] = None
|
||||
comfyui_default_model: Optional[str] = None
|
||||
replicate_api_key: Optional[str] = None
|
||||
stability_api_key: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/config")
|
||||
async def update_config(req: ConfigUpdateRequest):
|
||||
"""
|
||||
Apply runtime provider settings (no restart needed).
|
||||
Values are applied to the live settings object in-process.
|
||||
They do NOT persist across restarts — set them in .env for permanence.
|
||||
"""
|
||||
from app.config import settings
|
||||
|
||||
if req.ai_provider is not None:
|
||||
settings.ai_provider = req.ai_provider
|
||||
if req.openai_api_key:
|
||||
settings.openai_api_key = req.openai_api_key
|
||||
if req.openai_model:
|
||||
settings.openai_model = req.openai_model
|
||||
if req.invokeai_url is not None:
|
||||
settings.invokeai_url = req.invokeai_url
|
||||
if req.invokeai_default_model:
|
||||
settings.invokeai_default_model = req.invokeai_default_model
|
||||
if req.comfyui_url is not None:
|
||||
settings.comfyui_url = req.comfyui_url
|
||||
if req.comfyui_default_model:
|
||||
settings.comfyui_default_model = req.comfyui_default_model
|
||||
if req.replicate_api_key:
|
||||
settings.replicate_api_key = req.replicate_api_key
|
||||
if req.stability_api_key:
|
||||
settings.stability_api_key = req.stability_api_key
|
||||
|
||||
return {"status": "ok", "ai_provider": settings.ai_provider}
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
async def get_config():
|
||||
"""
|
||||
|
||||
@@ -48,9 +48,18 @@ export function hasRemote() {
|
||||
return !!(_caps?.remote?.healthy);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cache and re-fetch (call after saving provider settings).
|
||||
*/
|
||||
export async function refreshCapabilities() {
|
||||
_caps = null;
|
||||
_fetchPromise = null;
|
||||
return getCapabilities();
|
||||
}
|
||||
|
||||
/**
|
||||
* Kick off the fetch immediately at module load time so it's ready when tools need it.
|
||||
*/
|
||||
getCapabilities();
|
||||
|
||||
export default { getCapabilities, getCachedCapabilities, hasRemote };
|
||||
export default { getCapabilities, getCachedCapabilities, hasRemote, refreshCapabilities };
|
||||
|
||||
@@ -816,9 +816,32 @@ const menuDefinition = [
|
||||
name: 'Settings',
|
||||
ellipsis: true,
|
||||
target: 'tools/settings.settings'
|
||||
},
|
||||
{
|
||||
divider: true
|
||||
},
|
||||
{
|
||||
name: 'AI Provider Settings',
|
||||
ellipsis: true,
|
||||
target: 'tools/ai_provider_settings.ai_provider_settings'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Generate',
|
||||
children: [
|
||||
{
|
||||
name: 'Text → Image',
|
||||
ellipsis: true,
|
||||
target: 'generate/text_to_image.text_to_image'
|
||||
},
|
||||
{
|
||||
name: 'Expand Canvas (Outpaint)',
|
||||
ellipsis: true,
|
||||
target: 'generate/outpaint.outpaint'
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Help',
|
||||
children: [
|
||||
|
||||
@@ -133,6 +133,12 @@ config.TOOLS = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'ai_replace_selection',
|
||||
title: 'AI Replace Selection - Use any selection tool first',
|
||||
on_activate: 'on_activate',
|
||||
attributes: {},
|
||||
},
|
||||
{
|
||||
name: 'magic_wand',
|
||||
title: 'Magic Wand (Color Select)',
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Outpaint / Expand Canvas — remote provider fills the new region.
|
||||
* Menu target: generate/outpaint.outpaint
|
||||
*/
|
||||
|
||||
import app from './../../app.js';
|
||||
import config from './../../config.js';
|
||||
import Base_layers_class from './../../core/base-layers.js';
|
||||
import Dialog_class from './../../libs/popup.js';
|
||||
import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
|
||||
import apiService from './../../services/api.js';
|
||||
import { getCapabilities } from './../../api/capabilities.js';
|
||||
|
||||
var instance = null;
|
||||
|
||||
class Generate_outpaint_class {
|
||||
|
||||
constructor() {
|
||||
if (instance) return instance;
|
||||
instance = this;
|
||||
this.Base_layers = new Base_layers_class();
|
||||
this.Dialog = new Dialog_class();
|
||||
this.isProcessing = false;
|
||||
}
|
||||
|
||||
async outpaint() {
|
||||
var caps = await getCapabilities();
|
||||
if (!caps.remote || !caps.remote.healthy) {
|
||||
alertify.error(
|
||||
'Expand Canvas requires a remote AI provider. ' +
|
||||
'Set AI_PROVIDER (openai / invokeai / comfyui) in .env and restart.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var _this = this;
|
||||
|
||||
this.Dialog.show({
|
||||
title: 'Expand Canvas (Outpaint)',
|
||||
params: [
|
||||
{
|
||||
name: 'direction',
|
||||
title: 'Expand direction:',
|
||||
value: 'right',
|
||||
values: ['right', 'left', 'bottom', 'top'],
|
||||
},
|
||||
{
|
||||
name: 'size',
|
||||
title: 'Pixels to add:',
|
||||
type: 'range',
|
||||
value: 256,
|
||||
range: [64, 1024],
|
||||
step: 64,
|
||||
},
|
||||
{
|
||||
name: 'prompt',
|
||||
title: 'Describe the expansion (optional):',
|
||||
value: '',
|
||||
placeholder: "e.g. 'continue the landscape', 'more sky and clouds'",
|
||||
},
|
||||
],
|
||||
on_finish: async function (params) {
|
||||
await _this._run(params);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async _run(params) {
|
||||
if (this.isProcessing) return;
|
||||
if (config.layer.type !== 'image') {
|
||||
alertify.error('Current layer must be an image.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isProcessing = true;
|
||||
alertify.message('Expanding canvas... please wait', 0);
|
||||
|
||||
try {
|
||||
var layerCanvas = document.createElement('canvas');
|
||||
layerCanvas.width = config.layer.width_original;
|
||||
layerCanvas.height = config.layer.height_original;
|
||||
layerCanvas.getContext('2d').drawImage(config.layer.link, 0, 0);
|
||||
var imageB64 = layerCanvas.toDataURL('image/png').split(',')[1];
|
||||
|
||||
var response = await fetch(
|
||||
(window.API_BASE_URL || '') + '/api/generate/outpaint',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
image: imageB64,
|
||||
direction: params.direction,
|
||||
size: params.size || 256,
|
||||
prompt: params.prompt || '',
|
||||
}),
|
||||
}
|
||||
);
|
||||
if (!response.ok) {
|
||||
var err = await response.json().catch(() => ({ detail: 'Unknown error' }));
|
||||
throw new Error(err.detail || 'Outpaint failed');
|
||||
}
|
||||
var result = await response.json();
|
||||
|
||||
var img = new Image();
|
||||
img.onload = () => {
|
||||
var newW = img.naturalWidth;
|
||||
var newH = img.naturalHeight;
|
||||
var resultCanvas = document.createElement('canvas');
|
||||
resultCanvas.width = newW;
|
||||
resultCanvas.height = newH;
|
||||
resultCanvas.getContext('2d').drawImage(img, 0, 0);
|
||||
|
||||
// Update canvas dimensions and replace layer
|
||||
config.WIDTH = newW;
|
||||
config.HEIGHT = newH;
|
||||
app.State.do_action(
|
||||
new app.Actions.Bundle_action('outpaint', 'Expand Canvas', [
|
||||
new app.Actions.Resize_canvas_action(newW, newH),
|
||||
new app.Actions.Update_layer_image_action(resultCanvas),
|
||||
])
|
||||
);
|
||||
|
||||
alertify.dismissAll();
|
||||
alertify.success('Canvas expanded!');
|
||||
this.isProcessing = false;
|
||||
};
|
||||
img.onerror = () => {
|
||||
alertify.dismissAll();
|
||||
alertify.error('Failed to load expanded image.');
|
||||
this.isProcessing = false;
|
||||
};
|
||||
img.src = 'data:image/png;base64,' + result.result;
|
||||
|
||||
} catch (err) {
|
||||
alertify.dismissAll();
|
||||
alertify.error('Outpaint failed: ' + (err.message || err));
|
||||
this.isProcessing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Generate_outpaint_class;
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Text → Image — opens a sidebar-style dialog, generates via remote provider,
|
||||
* pastes result as a new layer on the current canvas.
|
||||
*
|
||||
* Menu target: generate/text_to_image.text_to_image
|
||||
*/
|
||||
|
||||
import app from './../../app.js';
|
||||
import config from './../../config.js';
|
||||
import Base_layers_class from './../../core/base-layers.js';
|
||||
import Dialog_class from './../../libs/popup.js';
|
||||
import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
|
||||
import apiService from './../../services/api.js';
|
||||
import { getCapabilities } from './../../api/capabilities.js';
|
||||
|
||||
var instance = null;
|
||||
|
||||
class Generate_text_to_image_class {
|
||||
|
||||
constructor() {
|
||||
if (instance) return instance;
|
||||
instance = this;
|
||||
this.Base_layers = new Base_layers_class();
|
||||
this.Dialog = new Dialog_class();
|
||||
this.isProcessing = false;
|
||||
}
|
||||
|
||||
async text_to_image() {
|
||||
var caps = await getCapabilities();
|
||||
if (!caps.remote || !caps.remote.healthy) {
|
||||
alertify.error(
|
||||
'Text → Image requires a remote AI provider. ' +
|
||||
'Set AI_PROVIDER (openai / invokeai / comfyui) in .env and restart.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var _this = this;
|
||||
var canvasW = config.WIDTH || 1024;
|
||||
var canvasH = config.HEIGHT || 1024;
|
||||
|
||||
this.Dialog.show({
|
||||
title: 'Text → Image',
|
||||
params: [
|
||||
{
|
||||
name: 'prompt',
|
||||
title: 'Describe your image:',
|
||||
type: 'textarea',
|
||||
value: '',
|
||||
placeholder: "e.g. 'a serene mountain lake at sunset, cinematic lighting'",
|
||||
},
|
||||
{
|
||||
name: 'negative_prompt',
|
||||
title: 'Avoid (optional):',
|
||||
value: '',
|
||||
placeholder: 'blurry, distorted, watermark',
|
||||
},
|
||||
{
|
||||
name: 'width',
|
||||
title: 'Width (px):',
|
||||
value: Math.min(canvasW, 1024),
|
||||
range: [256, 2048],
|
||||
step: 64,
|
||||
type: 'range',
|
||||
},
|
||||
{
|
||||
name: 'height',
|
||||
title: 'Height (px):',
|
||||
value: Math.min(canvasH, 1024),
|
||||
range: [256, 2048],
|
||||
step: 64,
|
||||
type: 'range',
|
||||
},
|
||||
{
|
||||
name: 'placement',
|
||||
title: 'Add as:',
|
||||
value: 'new_layer',
|
||||
values: ['new_layer', 'replace_canvas'],
|
||||
},
|
||||
{
|
||||
name: 'steps',
|
||||
title: 'Steps:',
|
||||
type: 'range',
|
||||
value: 30,
|
||||
range: [10, 60],
|
||||
step: 5,
|
||||
},
|
||||
{
|
||||
name: 'seed',
|
||||
title: 'Seed (0 = random):',
|
||||
value: 0,
|
||||
range: [0, 2147483647],
|
||||
step: 1,
|
||||
type: 'range',
|
||||
},
|
||||
],
|
||||
on_finish: async function (params) {
|
||||
if (!params.prompt || !params.prompt.trim()) {
|
||||
alertify.warning('Please enter a description.');
|
||||
return;
|
||||
}
|
||||
await _this._generate(params);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async _generate(params) {
|
||||
if (this.isProcessing) return;
|
||||
this.isProcessing = true;
|
||||
alertify.message('Generating image... please wait', 0);
|
||||
|
||||
try {
|
||||
var result = await apiService.textToImage(params.prompt, {
|
||||
width: params.width || 1024,
|
||||
height: params.height || 1024,
|
||||
negativePrompt: params.negative_prompt || '',
|
||||
steps: params.steps || 30,
|
||||
seed: params.seed || 0,
|
||||
});
|
||||
|
||||
var img = new Image();
|
||||
img.onload = () => {
|
||||
if (params.placement === 'replace_canvas') {
|
||||
// Resize canvas and replace bottom layer
|
||||
config.WIDTH = img.naturalWidth;
|
||||
config.HEIGHT = img.naturalHeight;
|
||||
var resultCanvas = document.createElement('canvas');
|
||||
resultCanvas.width = img.naturalWidth;
|
||||
resultCanvas.height = img.naturalHeight;
|
||||
resultCanvas.getContext('2d').drawImage(img, 0, 0);
|
||||
app.State.do_action(
|
||||
new app.Actions.Bundle_action('txt2img_replace', 'Text → Image', [
|
||||
new app.Actions.Update_layer_image_action(resultCanvas)
|
||||
])
|
||||
);
|
||||
} else {
|
||||
// Add as new layer on top
|
||||
var dataURL = img.src;
|
||||
app.State.do_action(
|
||||
new app.Actions.Bundle_action('txt2img_layer', 'Text → Image Layer', [
|
||||
new app.Actions.Insert_layer_action({
|
||||
name: params.prompt.slice(0, 30),
|
||||
type: 'image',
|
||||
data: dataURL,
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight,
|
||||
width_original: img.naturalWidth,
|
||||
height_original: img.naturalHeight,
|
||||
})
|
||||
])
|
||||
);
|
||||
}
|
||||
alertify.dismissAll();
|
||||
alertify.success('Image generated!');
|
||||
this.isProcessing = false;
|
||||
};
|
||||
img.onerror = () => {
|
||||
alertify.dismissAll();
|
||||
alertify.error('Failed to load generated image.');
|
||||
this.isProcessing = false;
|
||||
};
|
||||
img.src = 'data:image/png;base64,' + result.result;
|
||||
|
||||
} catch (err) {
|
||||
alertify.dismissAll();
|
||||
alertify.error('Generation failed: ' + (err.message || err));
|
||||
this.isProcessing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Generate_text_to_image_class;
|
||||
@@ -9,19 +9,23 @@ class Help_about_class {
|
||||
|
||||
//about
|
||||
about() {
|
||||
var email = 'www.viliusl@gmail.com';
|
||||
|
||||
var email = 'www.viliusl@gmail.com';
|
||||
|
||||
var settings = {
|
||||
title: 'About',
|
||||
params: [
|
||||
{title: "", html: '<img style="width:64px;" class="about-logo" alt="" src="images/logo-colors.png" />'},
|
||||
{title: "Name:", html: '<span class="about-name">miniPaint</span>'},
|
||||
{title: "Name:", html: '<span class="about-name">PaintPlus</span>'},
|
||||
{title: "Version:", value: VERSION},
|
||||
{title: "Description:", value: "Online image editor."},
|
||||
{title: "Author:", value: 'ViliusL'},
|
||||
{title: "Email:", html: '<a href="mailto:' + email + '">' + email + '</a>'},
|
||||
{title: "GitHub:", html: '<a href="https://github.com/viliusle/miniPaint">https://github.com/viliusle/miniPaint</a>'},
|
||||
{title: "Website:", html: '<a href="https://viliusle.github.io/miniPaint/">https://viliusle.github.io/miniPaint/</a>'},
|
||||
{title: "Description:", value: "Layer-based image editor with AI tools."},
|
||||
{title: "", html: '<hr style="margin:8px 0;border-color:#444;">'},
|
||||
{title: "Base:", html: '<a href="https://github.com/viliusle/miniPaint" target="_blank">miniPaint</a> by ViliusL'},
|
||||
{title: "AI Erase:", html: 'LaMa (Samsung Research) via <a href="https://github.com/enesmsahin/simple-lama-inpainting" target="_blank">simple-lama-inpainting</a>'},
|
||||
{title: "Bg Removal:", html: '<a href="https://github.com/danielgatis/rembg" target="_blank">rembg</a> / U2Net / OpenCV'},
|
||||
{title: "Smart Select:", html: '<a href="https://github.com/facebookresearch/segment-anything" target="_blank">SAM</a> (Meta AI)'},
|
||||
{title: "Remote AI:", html: 'InvokeAI · ComfyUI · OpenAI (user-configured)'},
|
||||
{title: "", html: '<hr style="margin:8px 0;border-color:#444;">'},
|
||||
{title: "GitHub:", html: '<a href="https://github.com/outis1one/EditmaskwithAI" target="_blank">outis1one/EditmaskwithAI</a>'},
|
||||
],
|
||||
};
|
||||
this.POP.show(settings);
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* AI Provider Settings — configure remote AI provider in-app without editing .env manually.
|
||||
* Settings are persisted to localStorage and sent to the backend config endpoint.
|
||||
* Menu target: tools/ai_provider_settings.ai_provider_settings
|
||||
*/
|
||||
|
||||
import Dialog_class from './../../libs/popup.js';
|
||||
import alertify from './../../../../node_modules/alertifyjs/build/alertify.min.js';
|
||||
import { getCapabilities } from './../../api/capabilities.js';
|
||||
|
||||
// localStorage key prefix
|
||||
const LS = 'paintplus_ai_';
|
||||
|
||||
function ls_get(key, def = '') {
|
||||
return localStorage.getItem(LS + key) ?? def;
|
||||
}
|
||||
function ls_set(key, val) {
|
||||
localStorage.setItem(LS + key, val);
|
||||
}
|
||||
|
||||
var instance = null;
|
||||
|
||||
class Tools_ai_provider_settings_class {
|
||||
|
||||
constructor() {
|
||||
if (instance) return instance;
|
||||
instance = this;
|
||||
this.POP = new Dialog_class();
|
||||
}
|
||||
|
||||
async ai_provider_settings() {
|
||||
var _this = this;
|
||||
var caps = await getCapabilities();
|
||||
var remote = caps.remote || {};
|
||||
var statusHtml = remote.provider
|
||||
? (remote.healthy
|
||||
? `<span style="color:#44cc44">● ${remote.provider} — connected</span>`
|
||||
: `<span style="color:#ffaa00">● ${remote.provider} — unreachable</span>`)
|
||||
: '<span style="color:#888">No remote provider configured</span>';
|
||||
|
||||
this.POP.show({
|
||||
title: 'AI Provider Settings',
|
||||
params: [
|
||||
{
|
||||
title: 'Status:',
|
||||
html: `<div style="margin:4px 0 8px;font-size:12px;">${statusHtml}</div>`,
|
||||
},
|
||||
{
|
||||
name: 'provider',
|
||||
title: 'Remote provider:',
|
||||
value: ls_get('provider', remote.provider || ''),
|
||||
values: ['', 'openai', 'invokeai', 'comfyui', 'replicate'],
|
||||
type: 'select',
|
||||
},
|
||||
// ── OpenAI ────────────────────────────────────────────────
|
||||
{
|
||||
name: 'openai_key',
|
||||
title: 'OpenAI API key:',
|
||||
value: ls_get('openai_key'),
|
||||
placeholder: 'sk-...',
|
||||
},
|
||||
{
|
||||
name: 'openai_model',
|
||||
title: 'OpenAI model:',
|
||||
value: ls_get('openai_model', 'dall-e-3'),
|
||||
values: ['dall-e-3', 'dall-e-2'],
|
||||
type: 'select',
|
||||
},
|
||||
// ── InvokeAI ──────────────────────────────────────────────
|
||||
{
|
||||
name: 'invokeai_url',
|
||||
title: 'InvokeAI URL:',
|
||||
value: ls_get('invokeai_url'),
|
||||
placeholder: 'http://192.168.1.x:9090',
|
||||
},
|
||||
{
|
||||
name: 'invokeai_model',
|
||||
title: 'InvokeAI default model:',
|
||||
value: ls_get('invokeai_model', 'flux-dev'),
|
||||
placeholder: 'flux-dev',
|
||||
},
|
||||
// ── ComfyUI ───────────────────────────────────────────────
|
||||
{
|
||||
name: 'comfyui_url',
|
||||
title: 'ComfyUI URL:',
|
||||
value: ls_get('comfyui_url'),
|
||||
placeholder: 'http://192.168.1.x:8188',
|
||||
},
|
||||
{
|
||||
name: 'comfyui_model',
|
||||
title: 'ComfyUI default checkpoint:',
|
||||
value: ls_get('comfyui_model', 'v1-5-pruned-emaonly.ckpt'),
|
||||
placeholder: 'v1-5-pruned-emaonly.ckpt',
|
||||
},
|
||||
// ── Replicate ─────────────────────────────────────────────
|
||||
{
|
||||
name: 'replicate_key',
|
||||
title: 'Replicate API key:',
|
||||
value: ls_get('replicate_key'),
|
||||
placeholder: 'r8_...',
|
||||
},
|
||||
],
|
||||
on_finish: async function (params) {
|
||||
await _this._save(params);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async _save(params) {
|
||||
// Persist to localStorage
|
||||
ls_set('provider', params.provider || '');
|
||||
ls_set('openai_key', params.openai_key || '');
|
||||
ls_set('openai_model', params.openai_model || 'dall-e-3');
|
||||
ls_set('invokeai_url', params.invokeai_url || '');
|
||||
ls_set('invokeai_model', params.invokeai_model || 'flux-dev');
|
||||
ls_set('comfyui_url', params.comfyui_url || '');
|
||||
ls_set('comfyui_model', params.comfyui_model || 'v1-5-pruned-emaonly.ckpt');
|
||||
ls_set('replicate_key', params.replicate_key || '');
|
||||
|
||||
// Push to backend (requires a running server that accepts runtime config)
|
||||
try {
|
||||
var payload = {
|
||||
ai_provider: params.provider || '',
|
||||
openai_api_key: params.openai_key || '',
|
||||
openai_model: params.openai_model || 'dall-e-3',
|
||||
invokeai_url: params.invokeai_url || '',
|
||||
invokeai_default_model: params.invokeai_model || 'flux-dev',
|
||||
comfyui_url: params.comfyui_url || '',
|
||||
comfyui_default_model: params.comfyui_model || '',
|
||||
replicate_api_key: params.replicate_key || '',
|
||||
};
|
||||
var base = window.API_BASE_URL || '';
|
||||
var r = await fetch(`${base}/api/config`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (r.ok) {
|
||||
alertify.success('AI provider settings saved. Testing connection...');
|
||||
var { refreshCapabilities } = await import('./../../api/capabilities.js');
|
||||
var caps = await refreshCapabilities();
|
||||
if (caps?.remote?.healthy) {
|
||||
alertify.success(`Connected to ${caps.remote.provider}!`);
|
||||
} else if (params.provider) {
|
||||
alertify.warning('Settings saved but provider is not reachable. Check URL/key.');
|
||||
}
|
||||
} else {
|
||||
// Server-side config update not supported — inform user to set .env
|
||||
alertify.warning(
|
||||
'Settings saved locally. To make them permanent, ' +
|
||||
'set these values in your .env file and restart the server.'
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
alertify.warning(
|
||||
'Settings saved locally. Set AI_PROVIDER and related keys in .env to make permanent.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Tools_ai_provider_settings_class;
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* AI Replace Selection — pick any selection (Smart Select, Magic Wand, Lasso, Brush Select),
|
||||
* describe what should go there, remote provider fills it in.
|
||||
*
|
||||
* Requires a configured remote provider (InvokeAI / ComfyUI / OpenAI).
|
||||
* Registered as tool name: "ai_replace_selection"
|
||||
*/
|
||||
|
||||
import app from './../app.js';
|
||||
import config from './../config.js';
|
||||
import Base_tools_class from './../core/base-tools.js';
|
||||
import Base_layers_class from './../core/base-layers.js';
|
||||
import Dialog_class from './../libs/popup.js';
|
||||
import alertify from './../../../node_modules/alertifyjs/build/alertify.min.js';
|
||||
import apiService from './../services/api.js';
|
||||
import { getCapabilities } from './../api/capabilities.js';
|
||||
|
||||
class Ai_replace_selection_class extends Base_tools_class {
|
||||
|
||||
constructor(ctx) {
|
||||
super();
|
||||
this.Base_layers = new Base_layers_class();
|
||||
this.POP = new Dialog_class();
|
||||
this.ctx = ctx;
|
||||
this.name = 'ai_replace_selection';
|
||||
this.isProcessing = false;
|
||||
}
|
||||
|
||||
load() {}
|
||||
|
||||
async on_activate() {
|
||||
var caps = await getCapabilities();
|
||||
if (!caps.remote || !caps.remote.healthy) {
|
||||
alertify.error(
|
||||
'Replace Selection requires a remote AI provider. ' +
|
||||
'Set AI_PROVIDER (openai / invokeai / comfyui) in .env and restart.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var hasMask = window.smartSelectMask?.canvas != null;
|
||||
var hasRect = this._getRectSelection() != null;
|
||||
|
||||
if (!hasMask && !hasRect) {
|
||||
alertify.warning(
|
||||
'No selection found. Use Smart Select, Magic Wand, Lasso, ' +
|
||||
'Ellipse Select, or Brush Select first, then activate this tool.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this._showDialog(caps.remote.provider);
|
||||
}
|
||||
|
||||
// ── Private ──────────────────────────────────────────────────────────────
|
||||
|
||||
_getRectSelection() {
|
||||
if (!config.layer) return null;
|
||||
var sel = config.layer.selection;
|
||||
if (!sel) return null;
|
||||
var { x, y, width, height } = sel;
|
||||
if (!width || !height) return null;
|
||||
return { x, y, width, height };
|
||||
}
|
||||
|
||||
_showDialog(providerName) {
|
||||
var _this = this;
|
||||
|
||||
this.POP.show({
|
||||
title: 'AI Replace Selection',
|
||||
params: [
|
||||
{
|
||||
name: 'prompt',
|
||||
title: 'Describe what to place here:',
|
||||
type: 'textarea',
|
||||
value: '',
|
||||
placeholder: "e.g. 'a blooming red rose', 'dark polished wood', 'a smiling golden retriever'",
|
||||
},
|
||||
{
|
||||
name: 'negative_prompt',
|
||||
title: 'Avoid (optional):',
|
||||
value: '',
|
||||
placeholder: 'blurry, distorted, low quality',
|
||||
},
|
||||
{
|
||||
name: 'steps',
|
||||
title: 'Steps:',
|
||||
type: 'range',
|
||||
value: 30,
|
||||
range: [10, 60],
|
||||
step: 5,
|
||||
},
|
||||
{
|
||||
name: 'cfg_scale',
|
||||
title: 'Prompt strength:',
|
||||
type: 'range',
|
||||
value: 75,
|
||||
range: [10, 100],
|
||||
step: 5,
|
||||
},
|
||||
],
|
||||
on_finish: function (params) {
|
||||
if (!params.prompt || !params.prompt.trim()) {
|
||||
alertify.warning('Please enter a description.');
|
||||
return;
|
||||
}
|
||||
_this._run(params);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async _run(params) {
|
||||
if (this.isProcessing) return;
|
||||
if (config.layer.type !== 'image') {
|
||||
alertify.error('Current layer must be an image.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isProcessing = true;
|
||||
alertify.message('Replacing selection... please wait', 0);
|
||||
|
||||
try {
|
||||
// Build mask canvas from current selection
|
||||
var maskCanvas = await this._buildMaskCanvas();
|
||||
if (!maskCanvas) {
|
||||
alertify.dismissAll();
|
||||
alertify.error('Could not build selection mask.');
|
||||
this.isProcessing = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Get layer as PNG
|
||||
var layerCanvas = document.createElement('canvas');
|
||||
layerCanvas.width = config.layer.width_original;
|
||||
layerCanvas.height = config.layer.height_original;
|
||||
layerCanvas.getContext('2d').drawImage(config.layer.link, 0, 0);
|
||||
|
||||
var imageB64 = layerCanvas.toDataURL('image/png').split(',')[1];
|
||||
var maskB64 = maskCanvas.toDataURL('image/png').split(',')[1];
|
||||
|
||||
var result = await apiService.remoteInpaint(
|
||||
imageB64, maskB64,
|
||||
params.prompt,
|
||||
{
|
||||
negativePrompt: params.negative_prompt || '',
|
||||
steps: params.steps || 30,
|
||||
cfgScale: (params.cfg_scale || 75) / 10,
|
||||
}
|
||||
);
|
||||
|
||||
var img = new Image();
|
||||
img.onload = () => {
|
||||
var resultCanvas = document.createElement('canvas');
|
||||
resultCanvas.width = config.layer.width_original;
|
||||
resultCanvas.height = config.layer.height_original;
|
||||
resultCanvas.getContext('2d').drawImage(img, 0, 0);
|
||||
|
||||
app.State.do_action(
|
||||
new app.Actions.Bundle_action('ai_replace_selection', 'AI Replace Selection', [
|
||||
new app.Actions.Update_layer_image_action(resultCanvas)
|
||||
])
|
||||
);
|
||||
|
||||
alertify.dismissAll();
|
||||
alertify.success('Done!');
|
||||
this.isProcessing = false;
|
||||
};
|
||||
img.onerror = () => {
|
||||
alertify.dismissAll();
|
||||
alertify.error('Failed to load result image.');
|
||||
this.isProcessing = false;
|
||||
};
|
||||
img.src = 'data:image/png;base64,' + result.result;
|
||||
|
||||
} catch (err) {
|
||||
alertify.dismissAll();
|
||||
alertify.error('Replace failed: ' + (err.message || err));
|
||||
this.isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async _buildMaskCanvas() {
|
||||
var w = config.layer.width_original;
|
||||
var h = config.layer.height_original;
|
||||
var canvas = document.createElement('canvas');
|
||||
canvas.width = w;
|
||||
canvas.height = h;
|
||||
var ctx = canvas.getContext('2d');
|
||||
|
||||
// Prefer smartSelectMask (all selection tools write here)
|
||||
if (window.smartSelectMask?.canvas) {
|
||||
ctx.drawImage(window.smartSelectMask.canvas, 0, 0, w, h);
|
||||
// Ensure pure B&W
|
||||
var d = ctx.getImageData(0, 0, w, h);
|
||||
for (var i = 0; i < d.data.length; i += 4) {
|
||||
var v = d.data[i] > 128 ? 255 : 0;
|
||||
d.data[i] = d.data[i+1] = d.data[i+2] = v;
|
||||
d.data[i+3] = 255;
|
||||
}
|
||||
ctx.putImageData(d, 0, 0);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
// Fall back to rectangular selection
|
||||
var sel = this._getRectSelection();
|
||||
if (sel) {
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.fillRect(sel.x, sel.y, sel.width, sel.height);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default Ai_replace_selection_class;
|
||||
Reference in New Issue
Block a user