mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-14 18:22:46 -06:00
feat(ai): separate optional image API key (#41)
Image generation reused the single (text-endpoint) API key, which breaks the common 'local LLM with no key + OpenAI for images' setup. Add an optional image_api_key (encrypted, write-only, never returned); generate-design uses it for image calls and falls back to the main key when blank (all-OpenAI setups). Local sd.cpp / ComfyUI still need no key. Schema column + migration.
This commit is contained in:
parent
c23e8ca289
commit
dc6424a3cc
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
Loading…
Reference in a new issue