Merge pull request #62 from screentinker/fix/ai-separate-image-key

feat(ai): separate optional image API key (#41)
This commit is contained in:
screentinker 2026-06-09 13:47:52 -05:00 committed by GitHub
commit c1aee36326
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 26 additions and 8 deletions

View file

@ -615,6 +615,9 @@ export default {
'designer.ai.image_base_url': 'Image endpoint URL',
'designer.ai.image_model': 'Image model',
'designer.ai.image_model_ph': 'optional — e.g. dall-e-3; blank for sd.cpp / ComfyUI',
'designer.ai.image_api_key': 'Image API key (optional)',
'designer.ai.image_key_ph': 'blank = reuse the key above',
'designer.ai.image_key_hint': 'Only if your image provider needs a different key than your text endpoint (e.g. local LLM + OpenAI images). Blank reuses the key above; local sd.cpp / ComfyUI need none.',
'designer.ai.failed': 'Generation failed',
'designer.ai.need_prompt': 'Enter a prompt first',
'designer.ai.settings_title': 'AI design settings',

View file

@ -360,6 +360,9 @@ async function openAiSettings() {
<input id="aiImageBaseUrl" class="input" value="${esc(cur.image_base_url || '')}" placeholder="http://localhost:8080/v1 · http://localhost:8188" style="width:100%"></div>
<div class="form-group"><label>${t('designer.ai.image_model')}</label>
<input id="aiImageModel" class="input" value="${esc(cur.image_model || '')}" placeholder="${t('designer.ai.image_model_ph')}" style="width:100%"></div>
<div class="form-group"><label>${t('designer.ai.image_api_key')}</label>
<input id="aiImageKey" class="input" type="password" autocomplete="off" placeholder="${cur.has_image_key ? t('designer.ai.key_set') : t('designer.ai.image_key_ph')}" style="width:100%">
<div style="font-size:11px;color:var(--text-muted);margin-top:4px">${t('designer.ai.image_key_hint')}</div></div>
<div id="aiSettingsErr" style="display:none;color:var(--danger);font-size:13px;margin-top:8px"></div>
</div>
<div class="modal-footer">
@ -406,6 +409,8 @@ async function openAiSettings() {
};
const key = overlay.querySelector('#aiKey').value;
if (key) data.api_key = key;
const imgKey = overlay.querySelector('#aiImageKey').value;
if (imgKey) data.image_api_key = imgKey;
try {
await api.aiSaveSettings(data);
showToast(t('designer.ai.saved'), 'success');

View file

@ -179,6 +179,8 @@ const migrations = [
"ALTER TABLE users ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0",
// #41 Phase 2: which image backend the workspace's image endpoint speaks.
"ALTER TABLE ai_settings ADD COLUMN image_provider TEXT",
// #41: optional separate key for the image endpoint (for local-LLM + cloud-image setups).
"ALTER TABLE ai_settings ADD COLUMN image_api_key_enc TEXT",
];
// Apply each ALTER idempotently. A "duplicate column name" / "already exists"
// error means the column is already present (expected on a migrated DB) - benign.

View file

@ -402,6 +402,7 @@ CREATE TABLE IF NOT EXISTS ai_settings (
image_base_url TEXT,
image_model TEXT,
image_provider TEXT,
image_api_key_enc TEXT,
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);

View file

@ -156,7 +156,7 @@ function deoverlapTexts(texts) {
// GET /api/ai/settings — workspace members (never returns the key)
router.get('/settings', (req, res) => {
const row = db.prepare('SELECT base_url, model, image_base_url, image_model, image_provider, api_key_enc FROM ai_settings WHERE workspace_id = ?').get(req.workspaceId);
const row = db.prepare('SELECT base_url, model, image_base_url, image_model, image_provider, api_key_enc, image_api_key_enc FROM ai_settings WHERE workspace_id = ?').get(req.workspaceId);
res.json({
base_url: row ? row.base_url || '' : '',
model: row ? row.model || '' : '',
@ -164,6 +164,7 @@ router.get('/settings', (req, res) => {
image_model: row ? row.image_model || '' : '',
image_provider: row ? row.image_provider || '' : '',
has_key: !!(row && row.api_key_enc),
has_image_key: !!(row && row.image_api_key_enc),
configured: !!(row && row.base_url && row.model),
image_configured: !!(row && row.image_base_url && row.image_provider),
});
@ -180,18 +181,22 @@ router.put('/settings', (req, res) => {
if (base_url && !endpointAllowed(base_url)) return res.status(400).json({ error: 'Endpoint URL not allowed (private/internal addresses are blocked on this instance).' });
if (image_base_url && !endpointAllowed(image_base_url)) return res.status(400).json({ error: 'Image endpoint URL not allowed.' });
const existing = db.prepare('SELECT api_key_enc FROM ai_settings WHERE workspace_id = ?').get(req.workspaceId);
const existing = db.prepare('SELECT api_key_enc, image_api_key_enc FROM ai_settings WHERE workspace_id = ?').get(req.workspaceId);
let api_key_enc = existing ? existing.api_key_enc : null;
if (typeof (req.body && req.body.api_key) === 'string' && req.body.api_key.length) api_key_enc = encrypt(req.body.api_key);
if (req.body && req.body.clear_key) api_key_enc = null;
let image_api_key_enc = existing ? existing.image_api_key_enc : null;
if (typeof (req.body && req.body.image_api_key) === 'string' && req.body.image_api_key.length) image_api_key_enc = encrypt(req.body.image_api_key);
if (req.body && req.body.clear_image_key) image_api_key_enc = null;
db.prepare(`
INSERT INTO ai_settings (workspace_id, base_url, api_key_enc, model, image_base_url, image_model, image_provider, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, strftime('%s','now'))
INSERT INTO ai_settings (workspace_id, base_url, api_key_enc, model, image_base_url, image_model, image_provider, image_api_key_enc, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, strftime('%s','now'))
ON CONFLICT(workspace_id) DO UPDATE SET base_url=excluded.base_url, api_key_enc=excluded.api_key_enc,
model=excluded.model, image_base_url=excluded.image_base_url, image_model=excluded.image_model,
image_provider=excluded.image_provider, updated_at=excluded.updated_at
`).run(req.workspaceId, base_url || null, api_key_enc, model || null, image_base_url || null, image_model || null, image_provider);
image_provider=excluded.image_provider, image_api_key_enc=excluded.image_api_key_enc, updated_at=excluded.updated_at
`).run(req.workspaceId, base_url || null, api_key_enc, model || null, image_base_url || null, image_model || null, image_provider, image_api_key_enc);
logActivity(req.user.id, 'ai_settings_update', `endpoint: ${base_url || '(none)'} model: ${model || '(none)'}`, null, getClientIp(req), req.workspaceId);
res.json({ ok: true });
});
@ -227,7 +232,7 @@ router.post('/generate-design', async (req, res) => {
const prompt = String(req.body && req.body.prompt || '').trim().slice(0, 500);
if (!prompt) return res.status(400).json({ error: 'Prompt required' });
const row = db.prepare('SELECT base_url, api_key_enc, model, image_base_url, image_model, image_provider FROM ai_settings WHERE workspace_id = ?').get(req.workspaceId);
const row = db.prepare('SELECT base_url, api_key_enc, model, image_base_url, image_model, image_provider, image_api_key_enc FROM ai_settings WHERE workspace_id = ?').get(req.workspaceId);
if (!row || !row.base_url || !row.model) return res.status(400).json({ error: 'AI is not configured. Set an endpoint and model in AI settings first.' });
if (!endpointAllowed(row.base_url)) return res.status(400).json({ error: 'Configured endpoint is not allowed.' });
@ -273,7 +278,9 @@ router.post('/generate-design', async (req, res) => {
// image never fails the whole design — the text/shapes still come back).
const imageEls = design.elements.filter((e) => e.type === 'image');
if (imagesAvailable && (design.background_prompt || imageEls.length)) {
const common = { provider: row.image_provider, baseUrl: imgBase, apiKey: key, model: row.image_model, timeoutMs: 180000 };
// Separate image key if set, else fall back to the text key (all-OpenAI setups).
const imgKey = decrypt(row.image_api_key_enc) || key;
const common = { provider: row.image_provider, baseUrl: imgBase, apiKey: imgKey, model: row.image_model, timeoutMs: 180000 };
const jobs = [];
if (design.background_prompt) {
jobs.push(generateImage({ ...common, prompt: design.background_prompt, width: 1024, height: 576 })