Refine recipe UI interactions
This commit is contained in:
@@ -154,3 +154,18 @@
|
||||
- `node --check` for every JavaScript file under `web/`
|
||||
- `python_embeded/python.exe -m py_compile api/__init__.py api/models.py api/recipes.py api/recipe_packages.py`
|
||||
- `git diff --check`
|
||||
|
||||
## [Snapshot] 2026-08-03 — Refine recipe detail UI
|
||||
|
||||
**Implemented**
|
||||
|
||||
- Added visible success/failure feedback to detail and card parameter-copy actions.
|
||||
- Added inline editing for recipe name, notes, and tags; metadata updates send the complete existing recipe payload through `/anomalous/update_recipe` and preserve normal history behavior.
|
||||
- Moved workflow fingerprints and model SHA-256 values into collapsed Advanced information disclosures.
|
||||
- Added restrained glass card styling, responsive grid spacing, modern font fallbacks, rounded action controls, and hover transitions.
|
||||
|
||||
**Validation**
|
||||
|
||||
- `node --check` for every JavaScript file under `web/`
|
||||
- `python_embeded/python.exe -m py_compile api/__init__.py api/models.py api/recipes.py api/recipe_packages.py`
|
||||
- `git diff --check`
|
||||
|
||||
@@ -327,3 +327,9 @@ This document serves as an architectural retrospective and UX diagnostic log for
|
||||
(脱离画布的快速排队在正常 ComfyUI 画布之外又建立了一条执行路径。提示词转换、排队契约、校验和 UI 反馈都要单独维护,容易造成不同入口行为漂移,也难以解释失败原因。)
|
||||
**The Solution (方案)**: Remove the detached execution surface and make recipe actions load or transactionally append the saved workflow through shared handlers. List cards and detail panels now reuse the same confirmation, missing-node warning, error feedback, and successful Workspace closure behavior.
|
||||
(移除脱离画布的执行入口,让配方动作通过共享处理器加载或事务性追加已保存工作流。列表卡片和详情面板统一复用确认、缺失节点提示、错误反馈和成功关闭工作台的行为。)
|
||||
|
||||
## 53. Inline Metadata Editing Must Preserve the Full Recipe Contract (内联元数据编辑也必须保留完整配方契约)
|
||||
**The Problem (问题)**: The update endpoint validates a complete recipe, not a partial metadata patch. A convenient inline editor that sends only `name` or `notes` would either fail validation or tempt the backend to grow a second partial-update contract with weaker history semantics.
|
||||
(更新接口校验的是完整配方,而不是局部元数据补丁。内联编辑如果只发送 `name` 或 `notes`,要么直接失败,要么会诱导后端新增一套历史语义更弱的局部更新契约。)
|
||||
**The Solution (方案)**: Keep the inline editor lightweight at the UI layer, but clone and submit the already-loaded complete recipe through the existing update endpoint. Mutate the local view only after a successful response, then refresh the lightweight card list.
|
||||
(界面可以轻量内联编辑,但仍复制已加载的完整配方并走现有更新接口。只有收到成功响应后才更新当前视图,并刷新轻量卡片列表。)
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# Recipe 交互与视觉全面重构方案 (GPT 执行计划)
|
||||
|
||||
根据用户的最新反馈,当前的 Recipe 工作台存在以下痛点:
|
||||
1. **复制按钮无反馈**:点击某些复制按钮(如“复制提示词”、“复制指纹”)虽然实际复制成功,但视觉上没有任何变化,导致用户以为没点上。
|
||||
2. **编辑体验反人类**:修改名称等基本信息需要弹出一个巨大的复杂对话框,操作繁琐。
|
||||
3. **UI 过于“极客”**:界面展示了太多哈希值、底层标识,且缺乏现代美感。
|
||||
4. **历史负担为零**:现有的本地测试数据可以无视,直接重构数据结构或展示逻辑即可,无需为了向下兼容丑陋的老数据而妥协设计。
|
||||
|
||||
请严格按照以下步骤对代码进行大修:
|
||||
|
||||
---
|
||||
|
||||
## 步骤 1:修复复制反馈缺失 (Copy Feedback)
|
||||
|
||||
**目标文件**:`web/modules/ui_recipe_detail.js`
|
||||
|
||||
1. **统一的反馈封装**:编写一个全局的 `copyTextWithFeedback(buttonElement, text)` 辅助函数。
|
||||
- 内部调用 `navigator.clipboard.writeText(text)`。
|
||||
- 成功后,记录按钮原始的 `textContent`,将文本暂时改为 `✓ 已复制`(或带有 check 图标),并给按钮加上 `copied-success` 的 CSS 类(用于变绿等动画)。
|
||||
- 设置 `setTimeout` 在 1.2 秒后恢复原状。
|
||||
2. **替换无反馈的按钮调用**:
|
||||
- 在 `renderOverview` 中,找到 `copyPrompt.onclick` 和 `copyFingerprint.onclick`。
|
||||
- 将它们改为调用 `copyTextWithFeedback(copyPrompt, promptBundle)`,确保用户点击大按钮时有明显的成功提示。
|
||||
|
||||
---
|
||||
|
||||
## 步骤 2:告别反人类的编辑,实现“内联编辑 (Inline Edit)”
|
||||
|
||||
**目标文件**:`web/modules/ui_recipe_detail.js` (以及对应的 CSS 文件)
|
||||
|
||||
目前的 `editRecipe` 会弹出一个包含参数、封面、名字的巨大弹窗,极其笨重。我们改为**所见即所得**的内联编辑:
|
||||
1. **名称支持点击修改**:
|
||||
- 在 `renderOverview` 渲染 `<h3>${recipe.name}</h3>` 的地方,增加一个轻量的编辑图标(如 ✏️)。
|
||||
- 点击该标题后,将其替换为一个 `<input type="text">`,自动获得焦点。
|
||||
- 监听 `blur` 和 `keydown (Enter)` 事件。触发时,读取新值并直接调用 `fetch('/anomalous/update_recipe')` 保存到后台,保存成功后重新渲染标题。
|
||||
2. **备注 (Notes) 与标签 (Tags) 内联化**:
|
||||
- 对 Notes 文本同样应用内联编辑逻辑(点击变成 `<textarea>`)。
|
||||
- 让用户可以直接在详情页快速改名和改备注,日常使用中几乎再也不需要打开原先那个巨大的 `editRecipe` 弹窗。
|
||||
|
||||
---
|
||||
|
||||
## 步骤 3:UI 现代化与“去极客化” (Modern UI & De-geekify)
|
||||
|
||||
**目标文件**:`web/modules/ui_recipes.js` / `web/modules/ui_recipe_detail.js` / 对应 CSS
|
||||
|
||||
1. **隐藏冗余的硬核数据**:
|
||||
- 诸如 “工作流指纹 (Fingerprint)”、“模型 SHA-256 哈希值”等过于底层的信息,对普通画图用户来说视觉噪音极大。
|
||||
- 将这些信息包裹在一个原生的 `<details><summary>开发者/高级信息</summary>... </details>` 折叠面板中,默认收起。
|
||||
2. **引入高级的卡片设计 (Glassmorphism & Micro-animations)**:
|
||||
- 修改卡片(`.anomalous-recipe-card`)的 CSS,抛弃干瘪的实色背景。引入毛玻璃效果:`background: rgba(255,255,255,0.05); backdrop-filter: blur(10px);`(如果是暗色主题适用)。
|
||||
- 增加微动画交互:悬浮时增加轻微上浮 `transform: translateY(-2px);` 和发光阴影 `box-shadow: 0 8px 24px rgba(0,0,0,0.15);`。
|
||||
- 确保列表布局采用响应式 Grid 布局:`display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px;`。
|
||||
3. **按钮与排版升级**:
|
||||
- 使用现代无衬线字体(如 Inter, Roboto)。
|
||||
- 将主按钮的边角变得更加圆润 (`border-radius: 6px` 或 `8px`),去除默认边框,增加柔和的 Hover 过渡效果 `transition: all 0.2s ease;`。
|
||||
- 减少 Emoji 的滥用,可以使用更协调的 SVG 图标或者更纯净的排版。
|
||||
|
||||
## 执行记录
|
||||
|
||||
2026-08-03:已完成。复制反馈、名称/备注/标签内联编辑、高级信息折叠、玻璃卡片与响应式间距已落地;更新请求沿用完整配方契约,未新增数据结构或破坏性兼容分支。
|
||||
@@ -157,6 +157,8 @@ Workspace
|
||||
- Reproducibility summary: verified, unverified, unavailable, and missing-node counts.
|
||||
- Actions: edit metadata, open in canvas, append to canvas, copy prompt, copy workflow fingerprint.
|
||||
- Quick Queue is intentionally removed from the current product surface; recipes are opened or appended to the canvas before execution, so the detail panel must not expose an out-of-canvas queue action.
|
||||
- Name, notes, and tags support lightweight inline editing. Successful saves use the full existing recipe update contract so metadata changes preserve the workflow graph and create the normal history entry.
|
||||
- Workflow fingerprints and model SHA-256 values belong in a collapsed Advanced information disclosure, not the primary visual hierarchy. Copy actions still remain available there with visible feedback.
|
||||
- Do not place every model preview here; keep Overview calm and use the Models tab for visual inventory.
|
||||
|
||||
### 6.2 Models & reproducibility
|
||||
|
||||
@@ -108,6 +108,7 @@ Instead of fragmenting the class scope and losing context, we extracted all UI p
|
||||
* **Prompt Notes (`ui_notebooks.js`, legacy internal name)**: A specialized editor for composing prompts and drag-dropping LoRA/model nodes. Its shared user-facing surface is **Workspace / 创作工作台**, with **Prompt Notes / 提示词笔记** and **Workflow Recipes / 工作流配方** as its two sections. This presentation rename intentionally leaves notebook routes, storage, and internal property names stable for compatibility.
|
||||
* **Workflow Recipes (`ui_recipes.js`, `ui_recipe_detail.js`)**: A second section inside the shared Workspace modal, not a separate top-level navigation surface. Recipes save the complete serialized graph and add two summary layers: adapters for common model/LoRA/prompt/sampling semantics, plus bounded generic widget summaries for every node (including third-party nodes). Schema v3 introduced a canonical SHA-256 workflow fingerprint and explicit model-reference identity records; schema v4 adds optional recipe-owned model-preview snapshot descriptors. The card API stays light; the full graph and history are fetched only after edit, restore, or “View details.” The detail panel owns Overview, Models & reproducibility, Parameters, and Versions tabs with one vertical scroll region. Opening Models & reproducibility resolves current previews through `/anomalous/resolve_paths_to_previews` using `exact_only: true` and a category folder boundary, so it never launches a recursive model scan. Its availability refresh also accepts only bounded exact references and never computes a full-file hash. The save dialog may pin any safe JSON-serializable widget, bind an output cover, and explicitly retain bounded model-preview snapshots. The edit dialog can update recipe metadata, pins, and safe primitive widget values without loading a graph; it applies changes only to corresponding serialized widget slots and leaves complex/sensitive values opaque. Structural edits are explicitly loaded onto the canvas and saved back through the same update path. Every update archives the previous full recipe locally (bounded to 20 versions). `recipe_parser.js` never mutates the live canvas; an unfamiliar custom node may have a less detailed card but must never block saving. Restoring a graph or historical version requires explicit confirmation because it replaces user state.
|
||||
* **Recipe interaction extensions (`recipe_diff.js`, `recipe_actions.js`)**: Recipe cards filter only lightweight name/notes/tags metadata. Version comparison fetches one validated historical JSON and returns bounded semantic changes without instantiating or mutating either graph. Append clones serialized nodes into the live graph with collision-free IDs, transactional link remapping, placement, selection, and rollback. Open/Append UI actions share the same exported handlers from `ui_recipe_detail.js`, including dirty-canvas confirmation, missing-node warning, error feedback, and Workspace closure after success.
|
||||
* **Recipe detail presentation**: Copy actions use visible temporary success/failure feedback. Name, notes, and tags can be edited inline from the loaded detail view and are persisted through the existing full-recipe update contract, preserving the serialized workflow and history behavior. Workflow fingerprints and model SHA-256 values are advanced information shown only inside collapsed disclosure panels; card and detail surfaces use bounded glass-style cards, responsive grids, and restrained hover transitions.
|
||||
* **Doctor Panel (`ui_doctor.js`)**: Diagnoses model health for individual nodes or the entire workflow. Includes "View Profile" functionality.
|
||||
|
||||
### NON-NEGOTIABLE: Recipe Presentation & Model Preview Boundaries / 配方展示与模型预览边界
|
||||
|
||||
@@ -135,6 +135,17 @@ export const i18n = {
|
||||
recipeNodeParams: '节点参数',
|
||||
recipeUnknownNode: '未知节点',
|
||||
recipeCopyParameter: '复制参数值',
|
||||
recipeCopied: '已复制',
|
||||
recipeCopyFailed: '复制失败',
|
||||
recipeAdvancedInfo: '高级信息',
|
||||
recipeInlineEditName: '点击修改名称',
|
||||
recipeInlineEditNotes: '点击修改备注',
|
||||
recipeInlineEditTags: '编辑标签',
|
||||
recipeInlineSave: '保存',
|
||||
recipeInlineCancel: '取消',
|
||||
recipeInlineSaving: '保存中…',
|
||||
recipeInlineError: '保存失败,请重试。',
|
||||
recipeInlineNameRequired: '名称不能为空。',
|
||||
recipeSomeParamsInWorkflow: '其余复杂或敏感参数仍保存在完整工作流中。',
|
||||
recipeMoreNodesInWorkflow: '其余节点仍保存在完整工作流中。',
|
||||
recipeMissingNodes: '当前缺少以下节点类型,恢复后可能无法运行',
|
||||
@@ -362,6 +373,17 @@ export const i18n = {
|
||||
recipeNodeParams: 'Node parameters',
|
||||
recipeUnknownNode: 'Unknown node',
|
||||
recipeCopyParameter: 'Copy parameter value',
|
||||
recipeCopied: 'Copied',
|
||||
recipeCopyFailed: 'Copy failed',
|
||||
recipeAdvancedInfo: 'Advanced information',
|
||||
recipeInlineEditName: 'Click to edit the name',
|
||||
recipeInlineEditNotes: 'Click to edit notes',
|
||||
recipeInlineEditTags: 'Edit tags',
|
||||
recipeInlineSave: 'Save',
|
||||
recipeInlineCancel: 'Cancel',
|
||||
recipeInlineSaving: 'Saving…',
|
||||
recipeInlineError: 'Could not save. Try again.',
|
||||
recipeInlineNameRequired: 'Name cannot be empty.',
|
||||
recipeSomeParamsInWorkflow: 'Other complex or sensitive parameters remain in the full workflow.',
|
||||
recipeMoreNodesInWorkflow: 'Other nodes remain in the full workflow.',
|
||||
recipeMissingNodes: 'These node types are currently missing; the restored workflow may not run',
|
||||
|
||||
+180
-17
@@ -109,18 +109,113 @@ async function copyText(value) {
|
||||
}
|
||||
}
|
||||
|
||||
async function copyTextWithFeedback(buttonElement, value) {
|
||||
const original = buttonElement.textContent;
|
||||
const copied = await copyText(value);
|
||||
buttonElement.textContent = copied
|
||||
? `✓ ${t('recipeCopied')}`
|
||||
: `! ${t('recipeCopyFailed')}`;
|
||||
buttonElement.classList.toggle('copied-success', copied);
|
||||
buttonElement.classList.toggle('copied-failure', !copied);
|
||||
window.setTimeout(() => {
|
||||
buttonElement.textContent = original;
|
||||
buttonElement.classList.remove('copied-success', 'copied-failure');
|
||||
}, 1200);
|
||||
return copied;
|
||||
}
|
||||
|
||||
function appendCopyButton(parent, value, label = t('recipeCopyParameter')) {
|
||||
const copy = button(parent, '⧉', 'anomalous-recipe-copy-param anomalous-recipe-detail-copy');
|
||||
copy.title = label;
|
||||
copy.setAttribute('aria-label', label);
|
||||
copy.onclick = async () => {
|
||||
const original = copy.textContent;
|
||||
copy.textContent = await copyText(value) ? '✓' : '!';
|
||||
setTimeout(() => { copy.textContent = original; }, 1200);
|
||||
};
|
||||
copy.onclick = () => { void copyTextWithFeedback(copy, value); };
|
||||
return copy;
|
||||
}
|
||||
|
||||
async function updateInlineRecipeMetadata(owner, recipe, changes) {
|
||||
const filename = owner?.recipeDetailFilename;
|
||||
if (!filename) throw new Error('recipe metadata filename missing');
|
||||
const next = JSON.parse(JSON.stringify({ ...recipe, ...changes }));
|
||||
const response = await fetch('/anomalous/update_recipe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ filename, ...next }),
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok || payload.status !== 'success') throw new Error('recipe metadata update failed');
|
||||
Object.assign(recipe, changes, { updated_timestamp: Date.now() });
|
||||
await owner.refreshRecipes?.();
|
||||
}
|
||||
|
||||
function beginInlineEdit(owner, recipe, container, field, renderValue, options = {}) {
|
||||
const editor = document.createElement('div');
|
||||
editor.className = `anomalous-recipe-inline-editor${options.multiline ? ' is-multiline' : ''}`;
|
||||
const input = document.createElement(options.multiline ? 'textarea' : 'input');
|
||||
input.className = 'anomalous-recipe-inline-input';
|
||||
input.value = Array.isArray(recipe[field]) ? recipe[field].join(', ') : String(recipe[field] || '');
|
||||
if (!options.multiline) input.type = 'text';
|
||||
if (options.maxLength) input.maxLength = options.maxLength;
|
||||
if (options.multiline) input.rows = 3;
|
||||
const controls = document.createElement('div');
|
||||
controls.className = 'anomalous-recipe-inline-controls';
|
||||
const status = appendText(controls, 'small', '', 'anomalous-recipe-detail-muted');
|
||||
const save = button(controls, t('recipeInlineSave'), 'anomalous-btn-primary');
|
||||
const cancel = button(controls, t('recipeInlineCancel'), 'anomalous-btn-secondary');
|
||||
let finished = false;
|
||||
let ignoreBlur = false;
|
||||
const restore = () => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
renderValue(container);
|
||||
};
|
||||
const commit = async () => {
|
||||
if (finished) return;
|
||||
const raw = input.value.trim();
|
||||
const value = options.parse ? options.parse(raw) : raw;
|
||||
if (options.required && !value) {
|
||||
status.textContent = t('recipeInlineNameRequired');
|
||||
input.focus();
|
||||
return;
|
||||
}
|
||||
finished = true;
|
||||
save.disabled = true;
|
||||
cancel.disabled = true;
|
||||
status.textContent = t('recipeInlineSaving');
|
||||
try {
|
||||
await updateInlineRecipeMetadata(owner, recipe, { [field]: value });
|
||||
renderValue(container);
|
||||
} catch (error) {
|
||||
console.error('Could not update inline recipe metadata:', error);
|
||||
finished = false;
|
||||
save.disabled = false;
|
||||
cancel.disabled = false;
|
||||
status.textContent = t('recipeInlineError');
|
||||
}
|
||||
};
|
||||
input.addEventListener('blur', () => {
|
||||
if (!ignoreBlur) void commit();
|
||||
});
|
||||
input.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
ignoreBlur = true;
|
||||
restore();
|
||||
} else if (event.key === 'Enter' && (!options.multiline || event.ctrlKey || event.metaKey)) {
|
||||
event.preventDefault();
|
||||
ignoreBlur = true;
|
||||
void commit();
|
||||
}
|
||||
});
|
||||
save.addEventListener('pointerdown', () => { ignoreBlur = true; });
|
||||
save.onclick = () => { void commit(); };
|
||||
cancel.addEventListener('pointerdown', () => { ignoreBlur = true; });
|
||||
cancel.onclick = restore;
|
||||
editor.append(input, controls);
|
||||
container.replaceChildren(editor);
|
||||
input.focus();
|
||||
input.select?.();
|
||||
}
|
||||
|
||||
function needsExpansion(value) {
|
||||
const text = String(value || '');
|
||||
return text.length > 260 || text.split(/\r?\n/).length > 3;
|
||||
@@ -319,6 +414,57 @@ function renderPromptSection(parent, recipe) {
|
||||
return prompts;
|
||||
}
|
||||
|
||||
function renderInlineTitle(parent, owner, recipe) {
|
||||
parent.replaceChildren();
|
||||
const title = button(parent, recipe.name || t('recipeUntitled'), 'anomalous-recipe-inline-title');
|
||||
title.title = t('recipeInlineEditName');
|
||||
title.onclick = () => beginInlineEdit(
|
||||
owner,
|
||||
recipe,
|
||||
parent,
|
||||
'name',
|
||||
(target) => renderInlineTitle(target, owner, recipe),
|
||||
{ maxLength: 120, required: true },
|
||||
);
|
||||
}
|
||||
|
||||
function renderInlineNotes(parent, owner, recipe) {
|
||||
parent.replaceChildren();
|
||||
const notes = appendText(
|
||||
parent,
|
||||
'p',
|
||||
recipe.notes || t('recipeDetailNoNotes'),
|
||||
'anomalous-recipe-detail-muted anomalous-recipe-inline-editable',
|
||||
);
|
||||
notes.title = t('recipeInlineEditNotes');
|
||||
notes.onclick = () => beginInlineEdit(
|
||||
owner,
|
||||
recipe,
|
||||
parent,
|
||||
'notes',
|
||||
(target) => renderInlineNotes(target, owner, recipe),
|
||||
{ multiline: true, maxLength: 5000 },
|
||||
);
|
||||
if (recipe.notes) appendCopyButton(parent, recipe.notes);
|
||||
}
|
||||
|
||||
function renderInlineTags(parent, owner, recipe) {
|
||||
parent.replaceChildren();
|
||||
for (const tag of recipe.tags || []) appendText(parent, 'span', tag, 'anomalous-recipe-badge anomalous-recipe-badge-tag');
|
||||
const edit = button(parent, t('recipeInlineEditTags'), 'anomalous-recipe-inline-edit-button');
|
||||
edit.onclick = () => beginInlineEdit(
|
||||
owner,
|
||||
recipe,
|
||||
parent,
|
||||
'tags',
|
||||
(target) => renderInlineTags(target, owner, recipe),
|
||||
{
|
||||
maxLength: 300,
|
||||
parse: (value) => [...new Set(value.split(',').map((tag) => tag.trim()).filter(Boolean))].slice(0, 20),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function renderOverview(content, owner, recipe, references, finish) {
|
||||
const overview = document.createElement('div');
|
||||
overview.className = 'anomalous-recipe-detail-overview';
|
||||
@@ -332,15 +478,17 @@ function renderOverview(content, owner, recipe, references, finish) {
|
||||
}
|
||||
const copy = document.createElement('div');
|
||||
copy.className = 'anomalous-recipe-detail-hero-copy';
|
||||
appendText(copy, 'h3', recipe.name || t('recipeUntitled'));
|
||||
const title = document.createElement('div');
|
||||
title.className = 'anomalous-recipe-inline-title-row';
|
||||
renderInlineTitle(title, owner, recipe);
|
||||
copy.appendChild(title);
|
||||
const notes = document.createElement('div');
|
||||
notes.className = 'anomalous-recipe-detail-notes';
|
||||
appendText(notes, 'p', recipe.notes || t('recipeDetailNoNotes'), 'anomalous-recipe-detail-muted');
|
||||
if (recipe.notes) appendCopyButton(notes, recipe.notes);
|
||||
renderInlineNotes(notes, owner, recipe);
|
||||
copy.appendChild(notes);
|
||||
const tags = document.createElement('div');
|
||||
tags.className = 'anomalous-recipe-tags';
|
||||
for (const tag of recipe.tags || []) appendText(tags, 'span', tag, 'anomalous-recipe-badge anomalous-recipe-badge-tag');
|
||||
tags.className = 'anomalous-recipe-tags anomalous-recipe-detail-tags';
|
||||
renderInlineTags(tags, owner, recipe);
|
||||
copy.appendChild(tags);
|
||||
appendText(copy, 'small', `${t('recipeDetailUpdated')}: ${dateText(recipe.updated_timestamp || recipe.timestamp)}`, 'anomalous-recipe-detail-muted');
|
||||
hero.appendChild(copy);
|
||||
@@ -354,9 +502,19 @@ function renderOverview(content, owner, recipe, references, finish) {
|
||||
renderStat(stats, t('recipeDetailIdentity'), `${verified}/${references.length}`, verified === references.length ? 'good' : 'warn');
|
||||
renderStat(stats, t('recipeDetailUnverified'), String(unverified), unverified ? 'warn' : 'good');
|
||||
renderStat(stats, t('recipeDetailMissingNodes'), String(missing), missing ? 'warn' : 'good');
|
||||
renderStat(stats, t('recipeDetailFingerprint'), shortHash(fingerprintText(recipe)) || t('recipeDetailNotIndexed'));
|
||||
overview.appendChild(stats);
|
||||
|
||||
const advanced = document.createElement('details');
|
||||
advanced.className = 'anomalous-recipe-advanced-info';
|
||||
appendText(advanced, 'summary', t('recipeAdvancedInfo'));
|
||||
const fingerprint = document.createElement('div');
|
||||
fingerprint.className = 'anomalous-recipe-advanced-row';
|
||||
appendText(fingerprint, 'span', `${t('recipeDetailFingerprint')}:`);
|
||||
appendText(fingerprint, 'code', fingerprintText(recipe) || t('recipeDetailNotIndexed'));
|
||||
if (fingerprintText(recipe)) appendCopyButton(fingerprint, fingerprintText(recipe), t('recipeDetailCopyFingerprint'));
|
||||
advanced.appendChild(fingerprint);
|
||||
overview.appendChild(advanced);
|
||||
|
||||
const summary = document.createElement('section');
|
||||
summary.className = 'anomalous-recipe-detail-section';
|
||||
appendText(summary, 'h4', t('recipeDetailSummary'));
|
||||
@@ -395,10 +553,7 @@ function renderOverview(content, owner, recipe, references, finish) {
|
||||
prompts.negative.length ? `${t('recipeDetailNegativePrompt')}:\n${prompts.negative.join('\n\n')}` : '',
|
||||
].filter(Boolean).join('\n\n');
|
||||
copyPrompt.disabled = !promptBundle;
|
||||
copyPrompt.onclick = () => copyText(promptBundle);
|
||||
const copyFingerprint = button(actions, t('recipeDetailCopyFingerprint'), 'anomalous-btn-primary');
|
||||
copyFingerprint.disabled = !fingerprintText(recipe);
|
||||
copyFingerprint.onclick = () => copyText(fingerprintText(recipe));
|
||||
copyPrompt.onclick = () => { void copyTextWithFeedback(copyPrompt, promptBundle); };
|
||||
overview.appendChild(actions);
|
||||
content.appendChild(overview);
|
||||
}
|
||||
@@ -477,8 +632,16 @@ function renderModels(content, owner, recipe, references) {
|
||||
meta.className = 'anomalous-recipe-model-reference-meta';
|
||||
const identity = normaliseIdentity(reference.identity);
|
||||
if (identity.sha256) {
|
||||
appendText(meta, 'span', `SHA256 ${shortHash(identity.sha256)}`);
|
||||
appendCopyButton(meta, identity.sha256, t('recipeDetailCopyHash'));
|
||||
const advanced = document.createElement('details');
|
||||
advanced.className = 'anomalous-recipe-advanced-info anomalous-recipe-model-hash';
|
||||
appendText(advanced, 'summary', t('recipeAdvancedInfo'));
|
||||
const hash = document.createElement('div');
|
||||
hash.className = 'anomalous-recipe-advanced-row';
|
||||
appendText(hash, 'span', 'SHA256:');
|
||||
appendText(hash, 'code', identity.sha256);
|
||||
appendCopyButton(hash, identity.sha256, t('recipeDetailCopyHash'));
|
||||
advanced.appendChild(hash);
|
||||
meta.appendChild(advanced);
|
||||
}
|
||||
if (formatIdentitySize(identity.size)) appendText(meta, 'span', formatIdentitySize(identity.size));
|
||||
appendText(meta, 'span', reference.currentAvailability === 'available'
|
||||
|
||||
@@ -29,6 +29,23 @@ function appendText(parent, tagName, text, className = '') {
|
||||
return element;
|
||||
}
|
||||
|
||||
async function copyCardValueWithFeedback(buttonElement, value) {
|
||||
const original = buttonElement.textContent;
|
||||
try {
|
||||
await navigator.clipboard.writeText(String(value));
|
||||
buttonElement.textContent = `✓ ${t('recipeCopied')}`;
|
||||
buttonElement.classList.add('copied-success');
|
||||
} catch (error) {
|
||||
console.warn('Could not copy recipe parameter:', error);
|
||||
buttonElement.textContent = `! ${t('recipeCopyFailed')}`;
|
||||
buttonElement.classList.add('copied-failure');
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
buttonElement.textContent = original;
|
||||
buttonElement.classList.remove('copied-success', 'copied-failure');
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
function safeThumbnail(value) {
|
||||
return typeof value === 'string' && /^data:image\/(?:png|jpeg|webp);base64,/i.test(value)
|
||||
? value
|
||||
@@ -238,9 +255,7 @@ function appendNodeDetails(parent, params) {
|
||||
const copy = appendText(row, 'button', '⧉', 'anomalous-recipe-copy-param');
|
||||
copy.type = 'button';
|
||||
copy.title = t('recipeCopyParameter');
|
||||
copy.onclick = async () => {
|
||||
try { await navigator.clipboard.writeText(value); } catch (error) { console.warn('Could not copy recipe parameter:', error); }
|
||||
};
|
||||
copy.onclick = () => { void copyCardValueWithFeedback(copy, value); };
|
||||
nodeBlock.appendChild(row);
|
||||
}
|
||||
if ((node.widgetCount || 0) > (node.widgets?.length || 0)) {
|
||||
|
||||
+141
-3
@@ -1026,6 +1026,7 @@
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
font-family: Inter, Roboto, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.anomalous-recipe-actionbar {
|
||||
@@ -1038,7 +1039,7 @@
|
||||
.anomalous-recipe-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 14px;
|
||||
gap: 16px;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
}
|
||||
@@ -1049,9 +1050,18 @@
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border: 1px solid #454545;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
background: linear-gradient(145deg, #282828, #1d1d1d);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.12);
|
||||
backdrop-filter: blur(10px);
|
||||
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.anomalous-recipe-card:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: rgba(112, 178, 255, 0.42);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.anomalous-recipe-card h3 {
|
||||
@@ -1103,6 +1113,33 @@
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.anomalous-recipe-actions button,
|
||||
.anomalous-recipe-detail-actions button,
|
||||
.anomalous-recipe-inline-controls button {
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
|
||||
.anomalous-recipe-actions button:hover,
|
||||
.anomalous-recipe-detail-actions button:hover,
|
||||
.anomalous-recipe-inline-controls button:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.anomalous-recipe-copy-param.copied-success,
|
||||
.anomalous-recipe-actions button.copied-success,
|
||||
.anomalous-recipe-detail-actions button.copied-success {
|
||||
border-color: rgba(110, 231, 183, 0.7);
|
||||
color: #6ee7b7;
|
||||
}
|
||||
|
||||
.anomalous-recipe-copy-param.copied-failure,
|
||||
.anomalous-recipe-actions button.copied-failure,
|
||||
.anomalous-recipe-detail-actions button.copied-failure {
|
||||
border-color: rgba(255, 153, 153, 0.7);
|
||||
color: #ff9999;
|
||||
}
|
||||
|
||||
.anomalous-recipe-label {
|
||||
color: #999;
|
||||
}
|
||||
@@ -1422,6 +1459,7 @@
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: #202020;
|
||||
font-family: Inter, Roboto, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.anomalous-recipe-detail-header,
|
||||
@@ -1507,6 +1545,106 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.anomalous-recipe-inline-title-row {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.anomalous-recipe-inline-title {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
color: #f1f1f1;
|
||||
background: transparent;
|
||||
cursor: text;
|
||||
font: inherit;
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.anomalous-recipe-inline-title:hover,
|
||||
.anomalous-recipe-inline-editable:hover {
|
||||
color: #a9cdff;
|
||||
}
|
||||
|
||||
.anomalous-recipe-inline-input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
box-sizing: border-box;
|
||||
padding: 7px 9px;
|
||||
border: 1px solid rgba(112, 178, 255, 0.7);
|
||||
border-radius: 7px;
|
||||
color: #f4f4f4;
|
||||
background: #161616;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.anomalous-recipe-inline-editor.is-multiline .anomalous-recipe-inline-input {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.anomalous-recipe-inline-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.anomalous-recipe-inline-editable {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.anomalous-recipe-inline-edit-button {
|
||||
padding: 2px 6px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 6px;
|
||||
color: #a9cdff;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.anomalous-recipe-detail-advanced,
|
||||
.anomalous-recipe-advanced-info {
|
||||
color: #aaa;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.anomalous-recipe-advanced-info > summary {
|
||||
display: inline-block;
|
||||
color: #a9cdff;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.anomalous-recipe-advanced-row {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
margin-top: 7px;
|
||||
padding: 7px 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 7px;
|
||||
background: rgba(0, 0, 0, 0.14);
|
||||
}
|
||||
|
||||
.anomalous-recipe-advanced-row code {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.anomalous-recipe-model-hash {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.anomalous-recipe-detail-hero-copy h3 {
|
||||
margin: 0;
|
||||
color: #f1f1f1;
|
||||
|
||||
Reference in New Issue
Block a user