diff --git a/web/modules/ui_dialog.js b/web/modules/ui_dialog.js new file mode 100644 index 0000000..0725e43 --- /dev/null +++ b/web/modules/ui_dialog.js @@ -0,0 +1,138 @@ +export function anomalousAlert(message, title = 'Anomalous') { + return new Promise((resolve) => { + const overlay = document.createElement('div'); + overlay.style.position = 'fixed'; + overlay.style.inset = '0'; + overlay.style.zIndex = '999999'; + overlay.style.display = 'flex'; + overlay.style.alignItems = 'center'; + overlay.style.justifyContent = 'center'; + overlay.style.background = 'rgba(0, 0, 0, 0.6)'; + overlay.style.backdropFilter = 'blur(4px)'; + overlay.style.padding = '20px'; + overlay.style.boxSizing = 'border-box'; + + const dialog = document.createElement('div'); + dialog.style.background = 'linear-gradient(145deg, rgba(48, 49, 55, 0.98), rgba(27, 28, 33, 0.98))'; + dialog.style.border = '1px solid rgba(255, 255, 255, 0.12)'; + dialog.style.borderRadius = '16px'; + dialog.style.padding = '24px'; + dialog.style.maxWidth = '400px'; + dialog.style.width = '100%'; + dialog.style.maxHeight = '90vh'; + dialog.style.overflowY = 'auto'; + dialog.style.boxShadow = '0 24px 70px rgba(0, 0, 0, 0.55)'; + dialog.style.color = '#f3f4f6'; + dialog.style.display = 'flex'; + dialog.style.flexDirection = 'column'; + dialog.style.gap = '16px'; + + const heading = document.createElement('h3'); + heading.textContent = title; + heading.style.margin = '0'; + heading.style.fontSize = '1.25rem'; + + const text = document.createElement('p'); + text.textContent = message; + text.style.margin = '0'; + text.style.lineHeight = '1.5'; + text.style.whiteSpace = 'pre-wrap'; + text.style.color = '#ccc'; + + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.justifyContent = 'flex-end'; + footer.style.marginTop = '8px'; + + const okBtn = document.createElement('button'); + okBtn.textContent = window.anomalous_browser_lang === 'zh' ? '确定' : 'OK'; + okBtn.className = 'anomalous-btn-primary'; + okBtn.style.padding = '8px 24px'; + + const cleanup = () => { + overlay.remove(); + resolve(); + }; + + okBtn.onclick = cleanup; + + footer.appendChild(okBtn); + dialog.append(heading, text, footer); + overlay.appendChild(dialog); + document.body.appendChild(overlay); + okBtn.focus(); + }); +} + +export function anomalousConfirm(message, title = 'Anomalous') { + return new Promise((resolve) => { + const overlay = document.createElement('div'); + overlay.style.position = 'fixed'; + overlay.style.inset = '0'; + overlay.style.zIndex = '999999'; + overlay.style.display = 'flex'; + overlay.style.alignItems = 'center'; + overlay.style.justifyContent = 'center'; + overlay.style.background = 'rgba(0, 0, 0, 0.6)'; + overlay.style.backdropFilter = 'blur(4px)'; + overlay.style.padding = '20px'; + overlay.style.boxSizing = 'border-box'; + + const dialog = document.createElement('div'); + dialog.style.background = 'linear-gradient(145deg, rgba(48, 49, 55, 0.98), rgba(27, 28, 33, 0.98))'; + dialog.style.border = '1px solid rgba(255, 255, 255, 0.12)'; + dialog.style.borderRadius = '16px'; + dialog.style.padding = '24px'; + dialog.style.maxWidth = '400px'; + dialog.style.width = '100%'; + dialog.style.maxHeight = '90vh'; + dialog.style.overflowY = 'auto'; + dialog.style.boxShadow = '0 24px 70px rgba(0, 0, 0, 0.55)'; + dialog.style.color = '#f3f4f6'; + dialog.style.display = 'flex'; + dialog.style.flexDirection = 'column'; + dialog.style.gap = '16px'; + + const heading = document.createElement('h3'); + heading.textContent = title; + heading.style.margin = '0'; + heading.style.fontSize = '1.25rem'; + + const text = document.createElement('p'); + text.textContent = message; + text.style.margin = '0'; + text.style.lineHeight = '1.5'; + text.style.whiteSpace = 'pre-wrap'; + text.style.color = '#ccc'; + + const footer = document.createElement('div'); + footer.style.display = 'flex'; + footer.style.justifyContent = 'flex-end'; + footer.style.gap = '12px'; + footer.style.marginTop = '8px'; + + const cancelBtn = document.createElement('button'); + cancelBtn.textContent = window.anomalous_browser_lang === 'zh' ? '取消' : 'Cancel'; + cancelBtn.className = 'anomalous-btn-ghost'; + cancelBtn.style.padding = '8px 24px'; + + const okBtn = document.createElement('button'); + okBtn.textContent = window.anomalous_browser_lang === 'zh' ? '确定' : 'OK'; + okBtn.className = 'anomalous-btn-danger'; + okBtn.style.padding = '8px 24px'; + + const finish = (result) => { + overlay.remove(); + resolve(result); + }; + + cancelBtn.onclick = () => finish(false); + okBtn.onclick = () => finish(true); + + footer.append(cancelBtn, okBtn); + dialog.append(heading, text, footer); + overlay.appendChild(dialog); + document.body.appendChild(overlay); + cancelBtn.focus(); + }); +} diff --git a/web/modules/ui_recipe_detail.js b/web/modules/ui_recipe_detail.js index 39f966c..fae5e5c 100644 --- a/web/modules/ui_recipe_detail.js +++ b/web/modules/ui_recipe_detail.js @@ -1,5 +1,6 @@ import { app } from '../../../scripts/app.js'; -import { i18n } from './locales.js'; +import { i18n } from './locales.js'; +import { anomalousAlert, anomalousConfirm } from './ui_dialog.js'; import { deriveRecipeModelReferences, formatIdentitySize, @@ -66,7 +67,7 @@ export async function openRecipeOnCanvas(owner, recipe) { const missingWarning = missingTypes.length ? `\n\n${t('recipeMissingNodes')}:\n${missingTypes.slice(0, 12).join('\n')}` : ''; - if ((canvasMayContainUserWork() || missingWarning) && !confirm(`${t('recipeOpenCanvasConfirm')}${missingWarning}`)) return false; + if ((canvasMayContainUserWork() || missingWarning) && !await anomalousConfirm(`${t('recipeOpenCanvasConfirm')}${missingWarning}`)) return false; try { await app.loadGraphData(recipe.workflow); app.canvas?.setDirty?.(true, true); @@ -74,7 +75,7 @@ export async function openRecipeOnCanvas(owner, recipe) { return true; } catch (error) { console.error('Could not open Workflow Recipe on canvas:', error); - alert(t('recipeRestoreError')); + await anomalousAlert(t('recipeRestoreError')); return false; } } @@ -86,7 +87,7 @@ export function appendRecipeOnCanvas(owner, recipe) { return true; } catch (error) { console.error('Could not append Workflow Recipe:', error); - alert(error.code === 'recipe_append_missing_node' + await anomalousAlert(error.code === 'recipe_append_missing_node' ? `${t('recipeAppendError')}\n${error.message}` : t('recipeAppendError')); return false; @@ -657,39 +658,8 @@ function renderOverview(content, owner, recipe, references, finish) { const params = recipe.params || {}; const modelComposition = document.createElement('div'); modelComposition.className = 'anomalous-recipe-model-composition'; - appendText(modelComposition, 'h5', t('recipeDetailModelComposition')); - const modelCompositionGrid = document.createElement('div'); - modelCompositionGrid.className = 'anomalous-recipe-model-composition-grid'; - const baseModelCard = document.createElement('article'); - baseModelCard.className = 'anomalous-recipe-model-composition-card is-base-model'; - appendText(baseModelCard, 'span', t('recipeDetailBaseModel'), 'anomalous-recipe-detail-label'); - appendValueViewer( - baseModelCard, - modelDisplayName(params.baseModel) || t('recipeDetailUnavailable'), - 'anomalous-recipe-model-composition-value', - ); - modelCompositionGrid.appendChild(baseModelCard); - const loras = Array.isArray(params.loras) ? params.loras : []; - if (loras.length) { - loras.forEach((lora, index) => { - const loraCard = document.createElement('article'); - loraCard.className = 'anomalous-recipe-model-composition-card'; - appendText(loraCard, 'span', `${t('recipeDetailLoraSummary')} ${index + 1}`, 'anomalous-recipe-detail-label'); - appendValueViewer( - loraCard, - modelDisplayName(lora?.name) || t('recipeDetailUnavailable'), - 'anomalous-recipe-model-composition-value', - ); - if (lora?.strength_model !== null && lora?.strength_model !== undefined) { - appendText(loraCard, 'small', `× ${lora.strength_model}`, 'anomalous-recipe-detail-muted'); - } - modelCompositionGrid.appendChild(loraCard); - }); - } else { - appendText(modelCompositionGrid, 'small', `${t('recipeDetailLoraSummary')}: ${t('recipeDetailUnavailable')}`, 'anomalous-recipe-detail-muted'); - } - modelComposition.appendChild(modelCompositionGrid); summary.appendChild(modelComposition); + renderModelComposition(modelComposition, owner, recipe, references, finish, params); const values = [ [t('recipeSteps'), params.steps], ['CFG', params.cfg], @@ -733,12 +703,11 @@ function renderOverview(content, owner, recipe, references, finish) { content.appendChild(overview); } -function renderModels(content, owner, recipe, references, finish) { - const section = document.createElement('section'); - section.className = 'anomalous-recipe-detail-section'; +function renderModelComposition(container, owner, recipe, references, finish, params) { + container.replaceChildren(); const heading = document.createElement('div'); heading.className = 'anomalous-recipe-detail-section-heading'; - appendText(heading, 'h4', t('recipeDetailModels')); + appendText(heading, 'h5', t('recipeDetailModelComposition')); const refresh = button(heading, t('recipeDetailRefreshAvailability'), 'anomalous-btn-primary'); const status = appendText( heading, @@ -770,18 +739,16 @@ function renderModels(content, owner, recipe, references, finish) { } } await loadCurrentPreviews(owner, references); - content.replaceChildren(); - renderModels(content, owner, recipe, references, finish); + renderModelComposition(container, owner, recipe, references, finish, params); } catch (error) { console.error('Could not refresh recipe model availability:', error); status.textContent = t('recipeDetailRefreshError'); refresh.disabled = false; } }; - section.appendChild(heading); + container.appendChild(heading); if (!references.length) { - appendText(section, 'p', t('recipeDetailNoModelReferences'), 'anomalous-recipe-detail-muted'); - content.appendChild(section); + appendText(container, 'p', t('recipeDetailNoModelReferences'), 'anomalous-recipe-detail-muted'); return; } const list = document.createElement('div'); @@ -789,7 +756,8 @@ function renderModels(content, owner, recipe, references, finish) { for (const reference of references) { const card = document.createElement('article'); const isLocal = Boolean(reference.localModel); - card.className = `anomalous-recipe-model-reference${isLocal ? ' is-local' : ' is-unresolved'}`; + const isBase = params && params.baseModel && reference.saved_value === params.baseModel; + card.className = `anomalous-recipe-model-reference${isLocal ? ' is-local' : ' is-unresolved'}${isBase ? ' is-base-model' : ''}`; const body = document.createElement('div'); body.className = 'anomalous-recipe-model-reference-body'; const openModel = isLocal @@ -864,8 +832,7 @@ function renderModels(content, owner, recipe, references, finish) { match.onclick = async () => { match.disabled = true; await matchLocalModel(owner, recipe, reference, matchStatus, () => { - content.replaceChildren(); - renderModels(content, owner, recipe, references, finish); + renderModelComposition(container, owner, recipe, references, finish, params); }); if (!reference.localModel) match.disabled = false; }; @@ -875,8 +842,7 @@ function renderModels(content, owner, recipe, references, finish) { card.appendChild(body); list.appendChild(card); } - section.appendChild(list); - content.appendChild(section); + container.appendChild(list); } function parameterNodesWithPromptFallback(recipe) { @@ -1096,7 +1062,7 @@ function renderVersions(content, owner, recipe, history, finish) { }; const restore = button(row, t('recipeRestoreVersion'), 'anomalous-btn-danger'); restore.onclick = async () => { - if (!confirm(t('recipeRestoreVersionConfirm'))) return; + if (!await anomalousConfirm(t('recipeRestoreVersionConfirm'))) return; try { const response = await fetch('/anomalous/restore_recipe_version', { method: 'POST', @@ -1108,7 +1074,7 @@ function renderVersions(content, owner, recipe, history, finish) { finish('restored'); } catch (error) { console.error('Could not restore recipe version:', error); - alert(t('recipeUpdateError')); + await anomalousAlert(t('recipeUpdateError')); } }; timeline.appendChild(row); @@ -1187,7 +1153,7 @@ export function showRecipeDetail(owner, { recipe, filename, history = [] }) { const tabDefinitions = [ ['overview', t('recipeDetailOverview'), () => { renderOverview(content, owner, recipe, references, finish); - renderModels(content, owner, recipe, references, finish); + const paramsWrapper = document.createElement('details'); paramsWrapper.className = 'anomalous-recipe-detail-section'; const summary = document.createElement('summary'); diff --git a/web/modules/ui_recipes.js b/web/modules/ui_recipes.js index 6b95839..78d673e 100644 --- a/web/modules/ui_recipes.js +++ b/web/modules/ui_recipes.js @@ -1,7 +1,8 @@ /** Workflow Recipes UI, built on the same modal/card language as Notebooks. */ import { app } from '../../../scripts/app.js'; -import { i18n } from './locales.js'; +import { i18n } from './locales.js'; +import { anomalousAlert, anomalousConfirm } from './ui_dialog.js'; import { captureCanvasThumbnail, captureRecipeDraft, @@ -32,7 +33,7 @@ async function runRecipeCardAction(actionButton, action, errorKey) { await action(); } catch (error) { console.error('Workflow Recipe action failed:', error); - alert(t(errorKey)); + await anomalousAlert(t(errorKey)); } finally { actionButton.disabled = false; actionButton.classList.remove('is-busy'); @@ -108,9 +109,9 @@ function appendRecipeCover(parent, url, alt) { } async function exportRecipePackage(filename) { - const includeSnapshots = confirm(t('recipeExportSnapshotsConfirm')); - const includeHistory = confirm(t('recipeExportHistoryConfirm')); - const includeIdentity = !confirm(t('recipeExportRedactIdentityConfirm')); + const includeSnapshots = await anomalousConfirm(t('recipeExportSnapshotsConfirm')); + const includeHistory = await anomalousConfirm(t('recipeExportHistoryConfirm')); + const includeIdentity = !await anomalousConfirm(t('recipeExportRedactIdentityConfirm')); const response = await fetch('/anomalous/export_recipe_package', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -143,7 +144,7 @@ async function importRecipePackage(owner, file) { if (!inspectResponse.ok || inspectPayload.status !== 'success') throw new Error('recipe import inspection failed'); const recipeName = inspectPayload.recipe?.name || t('recipeUntitled'); const summary = `${t('recipeImportSummary')}\n\n${recipeName}\n${t('recipeImportAssets')}: ${inspectPayload.asset_count || 0}\n${t('recipeImportHistory')}: ${inspectPayload.history_count || 0}`; - if (!confirm(summary)) return; + if (!await anomalousConfirm(summary)) return; let name = recipeName; if ((inspectPayload.existing_names || []).includes(name)) { name = prompt(t('recipeImportRenamePrompt'), `${name} (Imported)`); @@ -418,7 +419,7 @@ async function editRecipe(owner, recipe, filename, history = null) { const result = await showRecipeEditDialog(owner, editable, filename, bundle.history); if (!result || result.mode === 'restored') return; if (result.mode === 'canvas') { - if (!confirm(t('recipeEditCanvasConfirm'))) return; + if (!await anomalousConfirm(t('recipeEditCanvasConfirm'))) return; app.loadGraphData(editable.workflow); app.canvas?.setDirty?.(true, true); owner.recipeEditing = { filename, data: editable }; @@ -443,7 +444,7 @@ async function editRecipe(owner, recipe, filename, history = null) { await owner.refreshRecipes(); } catch (error) { console.error('Could not edit Workflow Recipe:', error); - alert(t('recipeUpdateError')); + await anomalousAlert(t('recipeUpdateError')); } } @@ -839,7 +840,7 @@ function showRecipeEditDialog(owner, recipeData, filename, history) { const restore = appendText(row, 'button', t('recipeRestoreVersion'), 'anomalous-btn-danger'); restore.type = 'button'; restore.onclick = async () => { - if (!confirm(t('recipeRestoreVersionConfirm'))) return; + if (!await anomalousConfirm(t('recipeRestoreVersionConfirm'))) return; try { const response = await fetch('/anomalous/restore_recipe_version', { method: 'POST', @@ -852,7 +853,7 @@ function showRecipeEditDialog(owner, recipeData, filename, history) { resolve({ mode: 'restored' }); } catch (error) { console.error('Could not restore Workflow Recipe version:', error); - alert(t('recipeUpdateError')); + await anomalousAlert(t('recipeUpdateError')); } }; row.appendChild(restore); @@ -971,7 +972,7 @@ export async function showRecipes() { await importRecipePackage(this, file); } catch (error) { console.error('Could not import Workflow Recipe package:', error); - alert(t('recipeImportError')); + await anomalousAlert(t('recipeImportError')); } }; input.click(); @@ -1084,7 +1085,7 @@ export function renderRecipeList(recipes) { remove.onclick = (e) => { e.stopPropagation(); runRecipeCardAction(remove, async () => { - if (!confirm(t('recipeDeleteConfirm'))) return; + if (!await anomalousConfirm(t('recipeDeleteConfirm'))) return; const response = await fetch('/anomalous/delete_recipe', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -1124,12 +1125,12 @@ export function renderRecipeList(recipes) { export async function handleSaveRecipe() { if (!app.graph?.serialize) { - alert(t('recipeSaveError')); + await anomalousAlert(t('recipeSaveError')); return; } const draft = captureRecipeDraft(app.graph); if (!draft.workflow || !Array.isArray(draft.workflow.nodes)) { - alert(t('recipeSaveError')); + await anomalousAlert(t('recipeSaveError')); return; } const canvasThumbnail = captureCanvasThumbnail(app.canvas?.canvas); @@ -1187,7 +1188,7 @@ export async function handleSaveRecipe() { await this.refreshRecipes(); } catch (error) { console.error('Could not save Workflow Recipe:', error); - alert(t('recipeSaveError')); + await anomalousAlert(t('recipeSaveError')); } finally { if (saveButton) { saveButton.disabled = false;