diff --git a/backend/app/routers/ai_tools.py b/backend/app/routers/ai_tools.py
index edaa896..0f040aa 100644
--- a/backend/app/routers/ai_tools.py
+++ b/backend/app/routers/ai_tools.py
@@ -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():
"""
diff --git a/frontend/src/js/api/capabilities.js b/frontend/src/js/api/capabilities.js
index feb6307..b49b423 100644
--- a/frontend/src/js/api/capabilities.js
+++ b/frontend/src/js/api/capabilities.js
@@ -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 };
diff --git a/frontend/src/js/config-menu.js b/frontend/src/js/config-menu.js
index e99b763..2ab5d44 100644
--- a/frontend/src/js/config-menu.js
+++ b/frontend/src/js/config-menu.js
@@ -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: [
diff --git a/frontend/src/js/config.js b/frontend/src/js/config.js
index a2756fc..466112c 100644
--- a/frontend/src/js/config.js
+++ b/frontend/src/js/config.js
@@ -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)',
diff --git a/frontend/src/js/modules/generate/outpaint.js b/frontend/src/js/modules/generate/outpaint.js
new file mode 100644
index 0000000..cd7165a
--- /dev/null
+++ b/frontend/src/js/modules/generate/outpaint.js
@@ -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;
diff --git a/frontend/src/js/modules/generate/text_to_image.js b/frontend/src/js/modules/generate/text_to_image.js
new file mode 100644
index 0000000..d23ddca
--- /dev/null
+++ b/frontend/src/js/modules/generate/text_to_image.js
@@ -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;
diff --git a/frontend/src/js/modules/help/about.js b/frontend/src/js/modules/help/about.js
index 192984a..405f1f3 100644
--- a/frontend/src/js/modules/help/about.js
+++ b/frontend/src/js/modules/help/about.js
@@ -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: '
'},
- {title: "Name:", html: 'miniPaint'},
+ {title: "Name:", html: 'PaintPlus'},
{title: "Version:", value: VERSION},
- {title: "Description:", value: "Online image editor."},
- {title: "Author:", value: 'ViliusL'},
- {title: "Email:", html: '' + email + ''},
- {title: "GitHub:", html: 'https://github.com/viliusle/miniPaint'},
- {title: "Website:", html: 'https://viliusle.github.io/miniPaint/'},
+ {title: "Description:", value: "Layer-based image editor with AI tools."},
+ {title: "", html: '