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:
Claude
2026-06-09 17:54:44 +00:00
parent 27261c4ef4
commit b2a12c356f
9 changed files with 791 additions and 9 deletions
+10 -1
View File
@@ -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 };
+23
View File
@@ -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: [
+6
View File
@@ -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;
+12 -8
View File
@@ -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;