From b56cd844e0888bb8d87ff283ce9e3c7bb9eb5c23 Mon Sep 17 00:00:00 2001 From: DemonGatanjieu <1027066412@qq.com> Date: Mon, 3 Aug 2026 22:34:59 +0800 Subject: [PATCH] feat(recipes): match imported models locally --- .agents/logs/ai_changelog.md | 15 ++++ .agents/logs/ai_lessons.md | 5 +- ARCHITECTURE.md | 2 + web/modules/locales.js | 20 +++++ web/modules/ui_recipe_detail.js | 146 ++++++++++++++++++++++++++++---- web/styles.css | 9 ++ 6 files changed, 180 insertions(+), 17 deletions(-) diff --git a/.agents/logs/ai_changelog.md b/.agents/logs/ai_changelog.md index 27b7aa2..dafb3fe 100644 --- a/.agents/logs/ai_changelog.md +++ b/.agents/logs/ai_changelog.md @@ -529,4 +529,19 @@ - `node --check web/modules/ui_recipe_detail.js` - `python_embeded/python.exe -m py_compile api/recipes.py api/metadata.py` - `node tests/recipe_parser_roundtrip.mjs` +- `python_embeded/python.exe tests/test_recipe_roundtrip.py` +## [Snapshot] 2026-08-03 - Match imported recipe models locally + +**Implemented** + +- Replaced the detail-header origin refresh action with batch local model matching. +- Matching is constrained by model hash, file size, and model category; author paths and filenames are not identity evidence. +- Added an explicit Apply match action that updates the workflow only after confirmation and keeps the full-recipe history path. +- Added visible progress, summary, error feedback, and localized labels for the new flow. + +**Validation** + +- `node --check web/modules/ui_recipe_detail.js` +- `node --check web/modules/locales.js` +- `node tests/recipe_parser_roundtrip.mjs` - `python_embeded/python.exe tests/test_recipe_roundtrip.py` \ No newline at end of file diff --git a/.agents/logs/ai_lessons.md b/.agents/logs/ai_lessons.md index 3346132..22f257e 100644 --- a/.agents/logs/ai_lessons.md +++ b/.agents/logs/ai_lessons.md @@ -436,4 +436,7 @@ Browser `title` hints are delayed, low-contrast, difficult to style, and poor fo ## 64. Refresh Commands Need an Explicit Compact Contract **The Problem**: A UI action that only asks the server to refresh model identities may send `{filename, refreshIdentities}`. Routing that request through a complete-recipe validator makes the button fail even though the stored recipe is valid; a second partial-update path would also risk losing history guarantees. -**The Solution**: Detect the narrowly defined refresh-only payload, deep-copy the existing recipe, and run the same enrichment and archive/write path as a full update. Keep all other update payloads on complete-recipe validation, and match manual origin edits using stable reference provenance rather than display-only fields. \ No newline at end of file +**The Solution**: Detect the narrowly defined refresh-only payload, deep-copy the existing recipe, and run the same enrichment and archive/write path as a full update. Keep all other update payloads on complete-recipe validation, and match manual origin edits using stable reference provenance rather than display-only fields. +## 65. Imported Recipe Recovery Must Separate Matching from Applying +**The Problem**: An imported workflow stores the author's model path, which is often invalid on the current machine. Treating an exact-path availability refresh as identity matching makes the feature look broken; silently replacing the workflow after a hash match is also unsafe. +**The Solution**: Batch-resolve unresolved references by hash, exact size, and constrained model category while ignoring saved filenames and paths as identity evidence. Show the local candidate first, then require an explicit Apply action that updates the matching widget through the full-recipe update path and preserves history. \ No newline at end of file diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e3dd270..3b48201 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -16,6 +16,8 @@ Recipe origin editing has two supported update paths: complete recipe updates fo Origin edit dialogs locate a saved model reference by stable provenance fields (`node_id`, `widget_index`, category, and saved value), not by display-only fields such as `type`. Localized action labels must use keys that exist in the shared locale catalog; a missing key must never make a visible dialog action appear inert. +Recipe import matching is a separate, explicit local-recovery action. The recipe detail entry point sends all unresolved references to the hash-resolution batch endpoint, constrained by the reference's model category; the saved author path and filename are never identity evidence. A found local candidate is presentation-only until the user clicks `Apply match`, which updates the corresponding workflow widget through the full-recipe update contract and archives the prior recipe. Exact saved-path availability checks remain a separate lightweight preview operation. + ## Current recipe interaction correction (2026-08-03) The recipe detail view keeps Overview, Parameters, and Versions tabs. The Overview contains the model composition blocks; each resolved model name or preview is an explicit entry into the browser model detail view. Model previews are demand-loaded when Overview opens, and returning from model detail restores the recipe detail view. The model browser stores the active grid folder and scroll coordinates before opening detail and restores them when returning to the grid. List-card Append actions await the shared canvas transaction before reporting success or restoring the button state. Export confirmation uses three states (cancel, exclude, include); cancellation must stop the export flow rather than silently becoming an exclusion. diff --git a/web/modules/locales.js b/web/modules/locales.js index 2e993fb..3301903 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -230,6 +230,16 @@ export const i18n = { recipeOpenLocalModel: '打开本地模型详情', recipeMatchLocalModel: '匹配本地模型', recipeMatchingLocalModel: '正在匹配本地模型…', + recipeMatchRecipeModels: '匹配配方模型', + recipeMatchRecipeModelsDesc: '根据模型哈希、文件大小和模型类别,在本地模型目录中查找对应文件;不会使用原作者路径。', + recipeMatchingRecipeModels: '正在匹配配方模型…', + recipeRecipeMatchSummary: '已找到 {found}/{total} 个本地匹配', + recipeAllModelsMatched: '配方中的模型都已匹配或无需匹配', + recipeApplyLocalMatch: '应用此匹配', + recipeApplyLocalMatchDesc: '将匹配到的本地模型路径写入配方工作流', + recipeApplyingLocalMatch: '正在应用匹配…', + recipeApplyLocalMatchSuccess: '已应用本地模型匹配', + recipeApplyLocalMatchError: '无法应用此模型匹配', recipeLocalModelNotFound: '暂未找到匹配的本地模型。', recipeLocalModelAmbiguous: '找到多个候选,请先补充模型身份信息。', recipeLocalModelMatchError: '匹配失败,请稍后重试。', @@ -492,6 +502,16 @@ export const i18n = { recipeOpenLocalModel: 'Open local model details', recipeMatchLocalModel: 'Match local model', recipeMatchingLocalModel: 'Matching local model…', + recipeMatchRecipeModels: 'Match recipe models', + recipeMatchRecipeModelsDesc: 'Find local files by model hash, file size, and model category; the author’s path is not used.', + recipeMatchingRecipeModels: 'Matching recipe models…', + recipeRecipeMatchSummary: 'Found {found}/{total} local matches', + recipeAllModelsMatched: 'All recipe models are already matched or need no match', + recipeApplyLocalMatch: 'Apply match', + recipeApplyLocalMatchDesc: 'Write the matched local model path into the recipe workflow', + recipeApplyingLocalMatch: 'Applying match…', + recipeApplyLocalMatchSuccess: 'Local model match applied', + recipeApplyLocalMatchError: 'Could not apply this model match', recipeLocalModelNotFound: 'No matching local model was found.', recipeLocalModelAmbiguous: 'Multiple candidates found; add model identity information first.', recipeLocalModelMatchError: 'Matching failed. Try again later.', diff --git a/web/modules/ui_recipe_detail.js b/web/modules/ui_recipe_detail.js index 6a8968d..2a39e07 100644 --- a/web/modules/ui_recipe_detail.js +++ b/web/modules/ui_recipe_detail.js @@ -537,6 +537,105 @@ async function matchLocalModel(owner, recipe, reference, status, rerender) { } } +async function matchRecipeModels(owner, references, status, rerender) { + const candidates = references.filter((reference) => !reference.localModel); + const items = candidates.map((reference, index) => { + const identity = normaliseIdentity(reference.identity); + return { + key: String(index), + hash: identity.sha256 || 'unknown', + size: identity.size ?? null, + type: resolutionTypesForReference(reference).join(','), + }; + }); + if (!items.length) { + status.textContent = t('recipeAllModelsMatched'); + return { found: 0, total: 0 }; + } + + status.textContent = t('recipeMatchingRecipeModels'); + const response = await fetch('/anomalous/resolve_hash_batch', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ items }), + }); + const payload = await response.json(); + if (!response.ok || !Array.isArray(payload.results)) throw new Error('recipe model matching failed'); + + let found = 0; + for (const item of payload.results) { + const reference = candidates[Number(item.key)]; + const result = item.result; + if (!reference || !result?.found) continue; + let model; + try { + model = await resolveMatchedModelPreview(reference, result); + } catch (error) { + console.warn('Could not load preview for matched recipe model:', error); + continue; + } + if (!model) continue; + reference.localModel = model; + reference.currentPreviewUrl = model.preview_url || ''; + reference.currentAvailability = 'available'; + reference.localMatch = { + filename: model.filename, + type: model.type, + matched_by_hash: result.matched_by_hash === true, + matched_by_size: result.matched_by_size === true, + }; + found += 1; + } + rerender(); + return { found, total: candidates.length }; +} + +async function applyLocalModelMatch(owner, recipe, reference, status, rerender) { + const filename = reference.localMatch?.filename || ''; + const node = (recipe?.workflow?.nodes || []).find( + (candidate) => String(candidate?.id ?? '') === String(reference?.node_id ?? ''), + ); + const index = Number(reference?.widget_index); + if (!filename || !node || !Number.isInteger(index) || !Array.isArray(node.widgets_values) || index < 0 || index >= node.widgets_values.length) { + status.textContent = t('recipeApplyLocalMatchError'); + return false; + } + + const workflow = JSON.parse(JSON.stringify(recipe.workflow)); + const target = workflow.nodes.find((candidate) => String(candidate?.id ?? '') === String(reference.node_id ?? '')); + if (!target || !Array.isArray(target.widgets_values)) { + status.textContent = t('recipeApplyLocalMatchError'); + return false; + } + target.widgets_values[index] = filename; + + const params = JSON.parse(JSON.stringify(recipe.params || {})); + if (Array.isArray(params.model_references)) { + const stored = params.model_references.find((candidate) => ( + String(candidate?.node_id ?? '') === String(reference?.node_id ?? '') + && Number(candidate?.widget_index) === index + && String(candidate?.category || '') === String(reference?.category || '') + && candidate?.saved_value === reference?.saved_value + )); + if (stored) stored.saved_value = filename; + } + + status.textContent = t('recipeApplyingLocalMatch'); + try { + await updateInlineRecipeMetadata(owner, recipe, { workflow, params }); + reference.saved_value = filename; + reference.localMatch = null; + reference.currentAvailability = 'available'; + rerender(); + status.textContent = t('recipeApplyLocalMatchSuccess'); + return true; + } catch (error) { + console.error('Could not apply local recipe model match:', error); + status.textContent = t('recipeApplyLocalMatchError'); + return false; + } +} + function missingNodeTypes(recipe) { const registry = globalThis.LiteGraph?.registered_node_types; if (!registry) return []; @@ -1131,6 +1230,18 @@ function renderModelComposition(container, owner, recipe, references, finish, pa if (!reference.localModel) match.disabled = false; }; } + if (reference.localMatch?.filename && reference.localMatch.filename !== reference.saved_value) { + const applyStatus = appendText(meta, 'small', '', 'anomalous-recipe-model-match-status'); + const apply = button(meta, t('recipeApplyLocalMatch'), 'anomalous-btn-ghost anomalous-recipe-model-match'); + apply.title = t('recipeApplyLocalMatchDesc'); + apply.onclick = async () => { + apply.disabled = true; + await applyLocalModelMatch(owner, recipe, reference, applyStatus, () => { + renderModelComposition(container, owner, recipe, references, finish, params); + }); + if (reference.localMatch?.filename) apply.disabled = false; + }; + } details.appendChild(meta); body.appendChild(details); card.appendChild(body); @@ -1536,25 +1647,28 @@ export function showRecipeDetail(owner, { recipe, filename, history = [] }) { appendText(header, 'h3', recipe.name || t('recipeUntitled')); const headerActions = document.createElement('div'); headerActions.className = 'anomalous-recipe-actions-primary'; - const refreshOrigin = button(headerActions, '🌍 ' + t('recipeDetailRefreshOrigin'), 'anomalous-btn-ghost'); - refreshOrigin.title = t('recipeDetailRefreshOriginDesc'); - refreshOrigin.onclick = async () => { - refreshOrigin.disabled = true; - refreshOrigin.textContent = '...'; + const matchRecipe = button(headerActions, '🔎 ' + t('recipeMatchRecipeModels'), 'anomalous-btn-ghost'); + matchRecipe.title = t('recipeMatchRecipeModelsDesc'); + const matchRecipeStatus = appendText(headerActions, 'small', '', 'anomalous-recipe-header-status'); + matchRecipeStatus.setAttribute('aria-live', 'polite'); + matchRecipe.onclick = async () => { + matchRecipe.disabled = true; + matchRecipe.classList.add('is-busy'); + matchRecipe.textContent = t('recipeMatchingRecipeModels'); try { - const response = await fetch('/anomalous/update_recipe', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ filename, refreshIdentities: true }), + const result = await matchRecipeModels(owner, references, matchRecipeStatus, () => { + if (owner.recipeDetailActiveTab === 'overview') selectTab('overview'); }); - if (!response.ok) throw new Error('refresh failed'); - const data = await response.json(); - if (data.status === 'success') { - if (owner.refreshRecipes) await owner.refreshRecipes(); - finish('refresh'); - } + matchRecipeStatus.textContent = result.total + ? t('recipeRecipeMatchSummary').replace('{found}', String(result.found)).replace('{total}', String(result.total)) + : t('recipeAllModelsMatched'); } catch (error) { - console.error('Could not refresh identities:', error); + console.error('Could not match recipe models locally:', error); + matchRecipeStatus.textContent = t('recipeLocalModelMatchError'); + } finally { + matchRecipe.disabled = false; + matchRecipe.classList.remove('is-busy'); + matchRecipe.textContent = '🔎 ' + t('recipeMatchRecipeModels'); } }; diff --git a/web/styles.css b/web/styles.css index 7a266fd..d82a6f9 100644 --- a/web/styles.css +++ b/web/styles.css @@ -2542,6 +2542,15 @@ margin-left: auto; } +.anomalous-recipe-header-status { + max-width: 190px; + color: rgba(190, 205, 225, 0.78); + font-size: 0.75rem; + line-height: 1.25; + white-space: normal; + text-align: right; +} + /* Icon Button (for emoji/icons) */ .anomalous-btn-icon { display: inline-flex;