diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 31534d3..04d9dce 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -318,11 +318,11 @@ covered by the `styles.css` manifest. `shortcut_controls.js` owns the open-browser and material-library keyboard shortcuts, their fallback when ComfyUI's keybinding does not fire, and the settings control that opens ComfyUI's keybinding editor. -- `ui_model_sources.js` owns the Model Sources Hub, managing workflow-model and global-library source detection, Civitai/HuggingFace URL attribution, sidecar persistence, and resilient scope switching between active workflow and full local library (with cached library state preservation and reliable re-rendering). +- `ui_model_sources.js` renders the Models page's Sources view (the last type chip): where each model is downloaded, for the open workflow or every model, with each link editable and saved in place; `model_source_links.js` owns its data and actions (collecting the workflow's models, resolving them here, saving a link to the model's information and the workflow, the canvas note and the clipboard list). - `ui_materials.js`, `ui_material_cards.js`, and `ui_material_application.js` own the Material Library UI, category navigation, card presentation (with grab cursor affordances, explicit drag tooltips, and polymorphic card dragging via `bindPolymorphicMaterialCardDrag`), context-aware drag guidance, prompt drops into the prompt box under the pointer (`prompt_drop.js`), and the structured empty state onboarding blueprint guiding users through collection, canvas drag, and prompt studio mixing. Drag precedence prioritizes node hits over blank canvas drops; blank canvas drops create `CLIPTextEncode` nodes (one per side the prompt has) or open full workflows. `node_material_actions.js` owns prompt envelope extraction (`extractMaterialPromptEnvelope`) shared by detail views, cards and canvas actions. - `ui_update_guide.js` and `update_guide_data.js` own the non-intrusive update guide modal (accessible via header button `#anomalous-update-notice-btn`, the Help modal and Home's "What's new"; the current guide's ID and steps live in `update_guide_data.js`) with full bilingual localization. `ui_spotlight_tour.js` provides the interactive spotlight mask tour (`startSpotlightTour`), gliding smooth focal box highlights across the rail's pages and tools with directional tooltip cards and keyboard navigation; steps whose target is not on screen are skipped. Other views pass their own `steps` (text from locale keys) and an optional `onClose`; the GPT-SoVITS import window does. - `tool_registry.js` holds the tool icons shared by the rail and Home. -- The rail's tool slots (`ui_rail_tools.js`, fixed) hold scan, doctor and current node; settings sits at the rail's bottom. Materials is a rail page. There is no toolbox: the other tools open from their pages (workflow share codes on Workflows, Prompt Studio and Prompt Notes on Materials, Model Sources on Models and in the doctor, translation in Current node). +- The rail's tool slots (`ui_rail_tools.js`, fixed) hold scan, doctor and current node; settings sits at the rail's bottom. Materials is a rail page. There is no toolbox: the other tools open from their pages (workflow share codes on Workflows, Prompt Studio and Prompt Notes on Materials, Model Sources as a view of Models, opened from the doctor too, translation in Current node). - `ui_model_types.js` owns the models page's type chips (one per models folder, with its count) and `owner.modelScope`, what the grid lists: a whole type, or one list folder shown as a crumb. The grid's cards set `currentType/PathIdx/Subfolder` to their own model's folder (`focusModel`), @@ -424,12 +424,10 @@ covered by the `styles.css` manifest. parameter sets) and works out what each would change on the node, keeping seeds, model files and missing choices. `model_picker.js`, `node_material_actions.js`, and `graph_splice.js` own the remaining explicit graph changes. -- `ui_model_sources.js` owns the Model Source Hub (模型来源统一中控中心), providing dual-scope - (Workflow and Library) source inspection, external platform jumping, canvas `Note` node generation, - `workflow.extra.anomalous_model_sources` metadata synchronization, automated asynchronous model - metadata resolution (`resolveWorkflowModelsMetadata`) via `/anomalous/resolve_paths_to_previews` with - local sidecar priority detection, and protected read-only link display with deliberate edit-mode - unlocking and dirty-state dynamic local persistence (hiding redundant `[Save Local]` buttons until links are modified). +- A model's download link lives in its user layer (`source_url` in `.anomalous.json`, via + `/anomalous/update_metadata`), the same field the model editor edits; for the open workflow's + models it is also kept in `workflow.extra.anomalous_model_sources`, so shared workflows carry it. + Workflow models are located here through `/anomalous/resolve_paths_to_previews`. - `locales.js` is the shared runtime string catalog. Existing inline bilingual UI strings remain migration debt; new strings belong in the catalog. - `styles.css` is the ordered import manifest for `web/styles/*.css`, which own diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b2280d..d15d8ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ - **Current node (当前节点, formerly Node Assistant)**: the panel no longer splits into "actions" and "parameter sets" tabs; it is one page with the model actions on top and **Parameters** below. Parameters lists every saved set of values for this kind of node in one list — from the material library and from recipes' parameter sets, the same values saved twice shown once — and each entry says exactly what it would change on the selected node ("steps: 20 → 30"). **Apply these N** changes only those; seeds and model files stay (change the model above), and the receipt undoes it. **Save these values** keeps the node's current values in the material library, so the next node of that kind can use them. - **Translation moved onto the node (翻译挪到节点上)**: Current node now lists the selected node's prompt boxes, each marked positive or negative by its wiring. A box written in Chinese has **Translate to English**, which writes the English back (the receipt undoes it); an English box has **Show in Chinese**, which shows the meaning underneath and writes nothing. The separate Prompt Translator window is gone; Prompt Studio keeps its own translate buttons. - **No more toolbox (工具箱拆掉了)**: every tool now opens from the page it belongs to. Workflow share codes (import / export) are the ⇅ button at the top right of **Workflows** (where a disabled import button used to sit); **Prompt Studio** and **Prompt Notes** are at the top right of **Materials**; **Model Sources** is at the end of the type chips on **Models**, and Model Check has **Where to download** for the open workflow's models; translation is on the prompt boxes in **Current node**. The rail keeps scan, Model Check and Current node. +- **Model sources on the Models page (模型来源进了模型页)**: the Sources chip at the end of the model types now shows, in place of the cards, where each model is downloaded, for **This workflow** or **All models**, filtered by with / without a link. Every link can be typed or pasted straight into its row (any site, not only Civitai), found automatically, opened and saved with one button; a link is kept in the model's own information (the same as the editor's download link) and, for this workflow's models, in the workflow too, so it travels when the workflow is shared. The list can go on the canvas as a note or to the clipboard. Model Check's **Where to download** opens it on This workflow. The separate pop-up, its lock-then-edit links and its four ways of saving are gone. - **One look everywhere (配色统一)**: pages made at different times used their own greys (bluish slate here, neutral grey there) and their own accent (sky blue, violet, indigo). They now all take the Home page's colours: neutral surfaces, the same text greys and one accent for links and selection, and they follow the Abyssal Scarlet theme too (the Material Library, Prompt Studio, voices and several dialogs used to stay blue-grey in it). The workflow share-code dialogs lost their blue and orange buttons for the usual ones. Status colours and prompt-role colours are unchanged. - **See it before you let go (拖的时候先看到结果)**: dragging a prompt over a prompt box shows, in that box and in the opposite box on the same sampler, the text releasing would put there; move away and they go back. Dragging saved values over a node of their kind lists what would change, such as "steps 12 → 28, cfg 4.5 → 5.5", or says nothing would change. - **The scan page in plain words (扫描页说人话)**: it says what a scan does (the file's fingerprint is looked up on Civitai), and why a model was not found: not published on Civitai (voice, upscale and detection models mostly come from HuggingFace or GitHub), trained or merged by you, or changed after download; it works as usual, and you can add its download link. A model Civitai does not know that is no known image model says it may be a voice or upscale model. Model files in other formats (.pth, .ckpt, .bin…), which scans never read, are listed under **Other formats** with why, so they are no longer a mystery. diff --git a/docs/architecture/frontend.md b/docs/architecture/frontend.md index 9783b67..7d62bce 100644 --- a/docs/architecture/frontend.md +++ b/docs/architecture/frontend.md @@ -146,14 +146,15 @@ query parameter. ## Model source component links -The source hub uses `inferModelFolderTypes` for workflow component labels and +The Sources view (`ui_model_sources.js`, data in `model_source_links.js`) uses +`inferModelFolderTypes` for workflow component labels and category-scoped, exact-path local metadata reads. Context requests are batched to respect the endpoint's 16-item limit. Missing source links and unavailable native model choices are independent states; dynamic choices are not treated as evidence of absence. Native extensionless component choices and PTH/GGUF references remain visible even when the backend cannot locate an individual file. -`model_source_data.js` owns library-result shaping and the source hub's pure +`model_source_data.js` owns library-result shaping and the Sources view's pure main/component grouping and filtering. CLIP, text encoder, CLIP Vision, VAE, and preview-VAE entries live in a session-local, default-collapsed disclosure; the source-status pills and search affect both main models and components. Collapsed diff --git a/web/modules/browser.js b/web/modules/browser.js index 61a64a3..9dc94c9 100644 --- a/web/modules/browser.js +++ b/web/modules/browser.js @@ -19,6 +19,7 @@ import { openPromptStudio } from './ui_prompt_composer.js'; import { closeUpdateGuide } from './ui_update_guide.js'; import { showImageWorkbench } from './ui_gallery_detail.js'; import { openDoctorPage } from './ui_doctor.js'; +import { showModelSources } from './ui_model_sources.js'; import { initAssistantPanel, renderAssistantModelCard, _loadAssistantHistory, diagnoseNode, openCurrentNode, openLoraInsertionPicker } from './ui_node_assistant.js'; import { _openGalleryReplacer } from './ui_node_model_picker.js'; import { renderAudioStudio, stopAudioStudioPlayback } from './ui_audio_studio.js'; @@ -137,6 +138,7 @@ export class AnomalousBrowser { } AnomalousBrowser.prototype.openDoctorPage = function () { openDoctorPage(this); }; +AnomalousBrowser.prototype.showModelSources = function (scope) { showModelSources(this, scope); }; AnomalousBrowser.prototype.diagnoseNode = diagnoseNode; AnomalousBrowser.prototype.openCurrentNode = function () { openCurrentNode(this); }; AnomalousBrowser.prototype.initAssistantPanel = initAssistantPanel; diff --git a/web/modules/locales.js b/web/modules/locales.js index 187f82b..b63298c 100644 --- a/web/modules/locales.js +++ b/web/modules/locales.js @@ -1031,49 +1031,53 @@ export const i18n = { promptLibraryReadOnly: "由源素材管理,不能在工坊删除;拼装台中的副本可自由编辑。", toolWorkflowTransferTitle: "工作流导入导出中心", toolPromptStudioShort: '提示词工坊', - toolModelSourcesTitle: '模型来源与发布页中控', + toolModelSourcesTitle: "每个模型去哪下载,链接可以改成任何网站", toolModelSourcesShort: '模型来源', + modelSourcesTitle: "模型来源", + modelSourcesLead: "每个模型从哪下载。链接存在模型自己的信息里(编辑窗口里的“下载地址”就是它),哪个网站都行;“当前工作流”里的链接还会存进工作流,分享时一起带走。", + modelSourcesOpen: "打开", + modelSourcesSave: "保存", + modelSourcesSavedBoth: "已存进模型信息和这个工作流", + modelSourcesSavedWorkflow: "已存进这个工作流(本机没有这个模型)", + modelSourcesSaveFailed: "没存上:{error}", + modelSourcesFound: "找到了,看一下再点保存", + modelSourcesNotFound: "没找到,可以自己粘贴链接", + modelSourcesStats: "已填链接 {done} / {total}", + modelSourcesKeepAll: "把链接都存进工作流", + modelSourcesKeptAll: "已把 {count} 个链接存进工作流", + modelSourcesWorkflowEmpty: "画布上没有用到模型", + modelSourcesNoteTitle: "模型来源清单", + modelSourcesNoteFile: "文件", + modelSourcesNoteLink: "链接", + modelSourcesNoteCount: "{count} 个模型", toolPromptNotesShort: '提示词笔记', - modelSourcesModalTitle: '🌐 模型来源统一中控中心', - modelSourcesScopeWorkflow: '当前工作流', - modelSourcesScopeLibrary: '全部本地模型库', - modelSourcesFilterAll: '全部', - modelSourcesFilterResolved: '已填写来源', - modelSourcesFilterUnresolved: '来源待补充', - modelSourcesSearchPlaceholder: '搜索模型名称 / 节点...', - modelSourcesAutoFetchAll: '⚡ 一键获取全部来源', - modelSourcesGenerateNoteNode: '📝 在画布生成来源便签', - modelSourcesSaveToWorkflow: '💾 保存至工作流', - modelSourcesSyncToWorkflow: '🔄 同步工作流来源', - modelSourcesCopySummary: '📋 复制当前可见范围', - modelSourcesCopied: '✓ 已复制模型清单到剪贴板!', - modelSourcesSavedToWorkflow: '✓ 已将模型来源保存至工作流!', - modelSourcesSyncedToWorkflow: '✓ 已同步更新工作流中的模型来源!', - modelSourcesNoteCreated: '✓ 已在画布生成【模型来源便签】节点!', - modelSourcesJump: '↗️ 直达', - modelSourcesAutoDetect: '⚡ 识别', - modelSourcesSaveLocal: '💾 记住到本地', - modelSourcesSavedLocal: '✓ 已将来源链接保存至本地模型信息!', - modelSourcesStatusMissingOnDisk: '本地未安装', - modelSourcesStatusReady: '本地就绪', - modelSourcesStatusUnfilled: '来源待补充', - modelSourcesStatusUnfilledOptional: '未填写(可选)', - modelSourcesComponentsAdvanced: '基础组件(高级) · {count}', - modelSourcesComponentsOptionalHint: '来源可选,不计入待补', - modelSourcesComponentsMissing: '缺少本地文件 × {count}', - modelSourcesComponentsNoSearchMatch: '当前搜索没有匹配的基础组件', - modelSourcesMainStats: '主要模型来源:已填写', - modelSourcesLibraryRangeHint: '本地库范围跟随设置 → 文件夹管理', - modelSourcesLibraryLoading: '正在盘点当前文件夹管理范围…', - modelSourcesLibraryLoadFailed: '本地模型库加载失败,请重试。', + modelSourcesScopeWorkflow: "当前工作流", + modelSourcesScopeLibrary: "全部模型", + modelSourcesFilterAll: "全部", + modelSourcesFilterResolved: "有链接", + modelSourcesFilterUnresolved: "没填链接", + modelSourcesSearchPlaceholder: "搜索模型或节点", + modelSourcesGenerateNoteNode: "写进画布便签", + modelSourcesCopySummary: "复制清单", + modelSourcesCopied: "已复制清单", + modelSourcesNoteCreated: "已在画布上加了一张来源便签", + modelSourcesAutoDetect: "自动查找", + modelSourcesSavedLocal: "已存进模型信息", + modelSourcesStatusMissingOnDisk: "本机没有", + modelSourcesStatusUnfilled: "没填链接", + modelSourcesStatusUnfilledOptional: "可以不填", + modelSourcesComponentsAdvanced: "基础组件 {count} 个", + modelSourcesComponentsOptionalHint: "VAE、文本编码器这类,链接可以不填", + modelSourcesLibraryLoading: "正在读取全部模型…", + modelSourcesLibraryLoadFailed: "全部模型没有读出来。", modelSourcesRetry: '重试', modelSourcesTypeTextEncoder: '文本编码器', modelSourcesTypeVae: 'VAE', modelSourcesTypeVaeApprox: '预览 VAE', modelSourcesTypeVisionEncoder: '视觉编码器', - modelSourcesLocalTargetUnknown: '尚未确定本地文件位置,可先将链接保存到工作流。', - modelSourcesNoModelsFound: '未检测到任何模型', - modelSourcesUrlPlaceholder: '输入或粘贴来源链接 (Civitai / Hugging Face / Liblib / 网盘 等)...', + modelSourcesLocalTargetUnknown: "本机找不到这个模型的文件,链接没地方存。", + modelSourcesNoModelsFound: "没有符合的模型", + modelSourcesUrlPlaceholder: "粘贴下载页链接,哪个网站都行", sidebarInjectHash: '🟢 注入工作流哈希', sidebarSkipHash: '⚪ 不注入工作流哈希', sidebarManageFolders: '文件夹管理', @@ -2915,49 +2919,53 @@ export const i18n = { promptLibraryReadOnly: "Managed by the source material; cannot be deleted here. Mixer copies remain editable.", toolWorkflowTransferTitle: "Workflow Import / Export Center", toolPromptStudioShort: 'Prompt Studio', - toolModelSourcesTitle: 'Model Source Hub', + toolModelSourcesTitle: "Where each model is downloaded; any site's link can go here", toolModelSourcesShort: 'Sources', + modelSourcesTitle: "Model sources", + modelSourcesLead: "Where each model is downloaded. A link is kept in the model's own information (the editor's download link is the same), from any site; links of this workflow's models are kept in the workflow too, so they travel with it.", + modelSourcesOpen: "Open", + modelSourcesSave: "Save", + modelSourcesSavedBoth: "Saved in the model's information and this workflow", + modelSourcesSavedWorkflow: "Saved in this workflow (the model is not on this computer)", + modelSourcesSaveFailed: "Not saved: {error}", + modelSourcesFound: "Found one; check it, then Save", + modelSourcesNotFound: "Nothing found; paste the link yourself", + modelSourcesStats: "With a link: {done} / {total}", + modelSourcesKeepAll: "Keep all links in the workflow", + modelSourcesKeptAll: "Kept {count} links in the workflow", + modelSourcesWorkflowEmpty: "The canvas uses no models", + modelSourcesNoteTitle: "Model sources", + modelSourcesNoteFile: "File", + modelSourcesNoteLink: "Link", + modelSourcesNoteCount: "{count} models", toolPromptNotesShort: 'Prompt Notes', - modelSourcesModalTitle: '🌐 Model Source Hub', - modelSourcesScopeWorkflow: 'Active Workflow', - modelSourcesScopeLibrary: 'All Local Models', - modelSourcesFilterAll: 'All', - modelSourcesFilterResolved: 'Source provided', - modelSourcesFilterUnresolved: 'Source needed', - modelSourcesSearchPlaceholder: 'Search model name or node...', - modelSourcesAutoFetchAll: '⚡ Auto-Fetch All', - modelSourcesGenerateNoteNode: '📝 Create Note on Canvas', - modelSourcesSaveToWorkflow: '💾 Save to Workflow', - modelSourcesSyncToWorkflow: '🔄 Sync to Workflow', - modelSourcesCopySummary: '📋 Copy Visible Range', - modelSourcesCopied: '✓ Model sources copied to clipboard!', - modelSourcesSavedToWorkflow: '✓ Model sources saved to workflow!', - modelSourcesSyncedToWorkflow: '✓ Model sources synced in workflow!', - modelSourcesNoteCreated: '✓ Created Model Sources Note node on canvas!', - modelSourcesJump: '↗️ Open', - modelSourcesAutoDetect: '⚡ Detect', - modelSourcesSaveLocal: '💾 Save Local', - modelSourcesSavedLocal: '✓ Saved source link to local model metadata!', - modelSourcesStatusMissingOnDisk: 'Not installed locally', - modelSourcesStatusReady: 'Installed', - modelSourcesStatusUnfilled: 'Source needed', - modelSourcesStatusUnfilledOptional: 'Not provided (optional)', - modelSourcesComponentsAdvanced: 'Foundation components (advanced) · {count}', - modelSourcesComponentsOptionalHint: 'Sources are optional and excluded from source-needed counts', - modelSourcesComponentsMissing: 'Missing local files × {count}', - modelSourcesComponentsNoSearchMatch: 'No foundation components match this search', - modelSourcesMainStats: 'Main model sources filled', - modelSourcesLibraryRangeHint: 'Local library scope follows Settings → Folder Manager', - modelSourcesLibraryLoading: 'Loading the current Folder Manager scope…', - modelSourcesLibraryLoadFailed: 'Failed to load the local model library. Please retry.', + modelSourcesScopeWorkflow: "This workflow", + modelSourcesScopeLibrary: "All models", + modelSourcesFilterAll: "All", + modelSourcesFilterResolved: "With a link", + modelSourcesFilterUnresolved: "No link yet", + modelSourcesSearchPlaceholder: "Search models or nodes", + modelSourcesGenerateNoteNode: "Add a note to the canvas", + modelSourcesCopySummary: "Copy the list", + modelSourcesCopied: "List copied", + modelSourcesNoteCreated: "Added a sources note to the canvas", + modelSourcesAutoDetect: "Find it", + modelSourcesSavedLocal: "Saved in the model's information", + modelSourcesStatusMissingOnDisk: "Not on this computer", + modelSourcesStatusUnfilled: "no link yet", + modelSourcesStatusUnfilledOptional: "optional", + modelSourcesComponentsAdvanced: "{count} components", + modelSourcesComponentsOptionalHint: "VAE, text encoders and the like; a link is optional", + modelSourcesLibraryLoading: "Loading all models…", + modelSourcesLibraryLoadFailed: "All models could not be loaded.", modelSourcesRetry: 'Retry', modelSourcesTypeTextEncoder: 'Text encoder', modelSourcesTypeVae: 'VAE', modelSourcesTypeVaeApprox: 'Preview VAE', modelSourcesTypeVisionEncoder: 'Vision encoder', - modelSourcesLocalTargetUnknown: 'Local file location is not resolved. You can save the link to the workflow.', - modelSourcesNoModelsFound: 'No models detected', - modelSourcesUrlPlaceholder: 'Enter or paste source URL (Civitai / Hugging Face / Liblib / etc)...', + modelSourcesLocalTargetUnknown: "This model's file is not on this computer, so the link has nowhere to go.", + modelSourcesNoModelsFound: "No models match", + modelSourcesUrlPlaceholder: "Paste the download page's link, any site", sidebarInjectHash: '🟢 Inject Workflow Hash', sidebarSkipHash: '⚪ Skip Workflow Hash', sidebarManageFolders: 'Manage Folders', diff --git a/web/modules/model_source_links.js b/web/modules/model_source_links.js new file mode 100644 index 0000000..292893a --- /dev/null +++ b/web/modules/model_source_links.js @@ -0,0 +1,292 @@ +/** + * Where each model can be downloaded: the data and actions behind the Models page's + * "Sources" view (ui_model_sources.js renders it). The models of the open workflow come + * from its loader widgets; the library's from /anomalous/all_scan_models. A link is kept + * in the model's own information (the editor's download link) and, for a model of the + * open workflow, in the workflow too (extra.anomalous_model_sources), so a shared + * workflow carries it. + */ + +import { app } from '../../../scripts/app.js'; +import { translate as t } from './locales.js'; +import { jsonResponse } from './ui_dom.js'; +import { recordCanvasStep } from './canvas_history.js'; +import { inferModelFolderTypes, isPhysicalRenameProtectedType } from './model_policies.js'; +import { foundationModelType, shapeLibrarySourceModels, usableSourceUrl } from './model_source_data.js'; + +// A link's site, for its badge (the site's own colour). +const PLATFORMS = [ + [/civitai\.(com|red)/i, 'Civitai', '56, 189, 248'], + [/huggingface\.co/i, 'HuggingFace', '251, 191, 36'], + [/liblib/i, 'LiblibAI', '236, 72, 153'], + [/modelscope\.cn/i, 'ModelScope', '168, 85, 247'], + [/github\.com/i, 'GitHub', '52, 211, 153'], + [/pan\.baidu|123pan|quark|lanzou/i, 'CloudNet', '6, 182, 212'], +]; + +export function detectPlatform(url) { + const clean = typeof url === 'string' ? url.trim() : ''; + if (!clean) return null; + const [, name, rgb] = PLATFORMS.find(([pattern]) => pattern.test(clean)) || [null, 'Web Link', '']; + return rgb + ? { name, color: `rgb(${rgb})`, bg: `rgba(${rgb}, 0.15)`, border: `rgba(${rgb}, 0.4)` } + : { name, color: 'var(--amb-text-muted)', bg: 'transparent', border: 'var(--amb-border-strong)' }; +} + +export function isModelFilename(val) { + return typeof val === 'string' && /\.(safetensors|ckpt|pt|pth|bin|sft|gguf)$/i.test(val); +} + +export function normalizeUrl(url) { + const trimmed = typeof url === 'string' ? url.trim() : ''; + if (!trimmed) return ''; + return /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; +} + +/** Whether the model's file is known here, so its own information can keep the link. */ +export function canSaveLocalSource(model) { + return !model.isMissing && Boolean(model.type) && (!foundationModelType(model) || model.nodeId == null || Boolean(model.file_path)); +} + +/** The models the open workflow's loader widgets name, one per node and file. */ +export function collectWorkflowModels() { + const models = []; + const seen = new Set(); + const savedSources = app.graph?.extra?.anomalous_model_sources || {}; + const savedHashes = app.graph?.extra?.anomalous_hashes || {}; + + for (const node of app.graph?._nodes || []) { + if (!Array.isArray(node.widgets)) continue; + for (const w of node.widgets) { + const val = w.value; + const folderTypes = inferModelFolderTypes(node, w); + // Native component options such as TAESD need not have a file extension. + const componentOption = /^(vae_name|clip_name\d*|clip_vision(?:_name)?|text_encoder(?:_name)?\d*)$/i.test(w.name || '') + && folderTypes.some(isPhysicalRenameProtectedType) + && typeof val === 'string' && val.trim() && val.toLowerCase() !== 'none'; + if (!isModelFilename(val) && !componentOption) continue; + + const key = `${node.id}_${val}`; + if (seen.has(key)) continue; + seen.add(key); + + const basename = val.split(/[/\\]/).pop(); + const nativeValues = w.options?.values; + const isMissing = Array.isArray(nativeValues) + && !nativeValues.some(value => typeof value === 'string' && value.replaceAll('\\', '/') === val.replaceAll('\\', '/')); + const hashObj = savedHashes[key] || savedHashes[val] || window.anomalous_hash_cache?.[val] || window.anomalous_hash_cache?.[basename] || {}; + const saved = savedSources[val] || savedSources[basename] || savedSources[key]; + const url = usableSourceUrl(typeof saved === 'string' ? saved : saved?.url) + || usableSourceUrl(hashObj?.url) || usableSourceUrl(hashObj?.civitai_url); + + models.push({ + key, nodeId: node.id, nodeTitle: node.title || node.type || `#${node.id}`, nodeType: node.type, + folderTypes, filename: val, basename, isMissing, + hash: typeof hashObj === 'string' ? hashObj : (hashObj.hash || ''), + url, initialUrl: url, platform: detectPlatform(url), + }); + } + } + return models; +} + +/** Every model in the folders the browser shows. */ +export async function fetchAllLibraryModels(signal = null) { + const res = await fetch('/anomalous/all_scan_models?limit=0', { signal }); + const payload = await jsonResponse(res, 'load library models'); + return shapeLibrarySourceModels(payload.models, detectPlatform); +} + +/** Fills in where the workflow's models live here (type, folder) and links their information has. */ +export async function resolveWorkflowModelsMetadata(models, signal = null) { + if (!Array.isArray(models) || !models.length) return false; + const components = models.filter(m => foundationModelType(m)); + const requestedPaths = models.filter(m => !foundationModelType(m)).map(m => m.filename); + try { + const res = await fetch('/anomalous/resolve_paths_to_previews', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ paths: requestedPaths }), signal, + }); + const modelsMap = (await jsonResponse(res, 'resolve workflow models'))?.models || {}; + const componentModels = {}; + // The endpoint caps contextual lookups at 16; keep large workflows complete. + for (let offset = 0; offset < components.length; offset += 16) { + const response = await fetch('/anomalous/resolve_paths_to_previews', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, signal, + body: JSON.stringify({ paths: [], context_requests: components.slice(offset, offset + 16).map(m => ({ + key: m.key, path: m.relPath || m.filename, folder_types: m.folderTypes || [m.type], exact_only: true, + })) }), + }); + Object.assign(componentModels, (await jsonResponse(response, 'resolve component sources')).context_models || {}); + } + let changed = false; + for (const m of models) { + const info = foundationModelType(m) ? componentModels[m.key] : modelsMap[m.filename] || modelsMap[m.basename]; + if (!info) continue; + Object.assign(m, { type: info.type, subfolder: info.subfolder, path_idx: info.path_idx, file_path: info.file_path }); + if (foundationModelType(m)) m.isMissing = false; + if (!m.hash && info.metadata?.hash) m.hash = info.metadata.hash; + const resolvedUrl = usableSourceUrl(info.metadata?.source_url) || usableSourceUrl(info.metadata?.civitai_url); + if (!m.url && resolvedUrl) Object.assign(m, { url: resolvedUrl, initialUrl: resolvedUrl, platform: detectPlatform(resolvedUrl) }); + if (window.anomalous_hash_cache && m.filename && info.metadata?.hash) { + window.anomalous_hash_cache[m.filename] = { hash: info.metadata.hash, url: resolvedUrl }; + } + changed = true; + } + return changed; + } catch (e) { + if (!signal?.aborted) console.warn('[AMB] Model sources: could not resolve the workflow\'s models.', e); + return false; + } +} + +/** Writes `url` into the open workflow's sources for `model` ('' removes it); one Ctrl+Z step. */ +function keepInWorkflow(model, url) { + if (!app.graph) return false; + app.graph.extra ||= {}; + const sources = { ...(app.graph.extra.anomalous_model_sources || {}) }; + if (url) { + sources[model.filename] = { name: model.filename, url, platform: detectPlatform(url)?.name || 'Custom', nodeId: model.nodeId, hash: model.hash || '', updated_at: Date.now() }; + } else { + delete sources[model.filename]; + } + app.graph.extra.anomalous_model_sources = sources; + app.canvas?.setDirty(true, true); + recordCanvasStep(app); + return true; +} + +/** + * Keeps a model's link: in its own information when the file is known here, and in the + * open workflow for one of its models. Returns where: { local, workflow }. + */ +export async function saveModelSource(model, rawUrl) { + const url = normalizeUrl(rawUrl); + const where = { local: false, workflow: false }; + if (canSaveLocalSource(model)) { + const res = await fetch('/anomalous/update_metadata', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + filename: model.basename || model.filename.split(/[/\\]/).pop(), + type: model.type, subfolder: model.subfolder ? `/${String(model.subfolder).replace(/^\/+/, '')}` : '/', + path_idx: model.path_idx || 0, custom_source_url: url, + }), + }); + const payload = await jsonResponse(res, 'save model source'); + if (payload.status !== 'success') throw new Error(payload.message || 'save model source failed'); + where.local = true; + } + if (model.nodeId != null) where.workflow = keepInWorkflow(model, url); + if (!where.local && !where.workflow) throw new Error(t('modelSourcesLocalTargetUnknown')); + Object.assign(model, { url, initialUrl: url, platform: detectPlatform(url) }); + return where; +} + +/** Puts every link the listed workflow models have into the workflow; how many. */ +export function keepAllInWorkflow(models) { + if (!app.graph) return 0; + app.graph.extra ||= {}; + const sources = { ...(app.graph.extra.anomalous_model_sources || {}) }; + let count = 0; + for (const m of models) { + if (!m.url) continue; + const url = normalizeUrl(m.url); + sources[m.filename] = { name: m.filename, url, platform: detectPlatform(url)?.name || 'Custom', nodeId: m.nodeId, hash: m.hash || '', updated_at: Date.now() }; + count++; + } + app.graph.extra.anomalous_model_sources = sources; + app.canvas?.setDirty(true, true); + recordCanvasStep(app); + return count; +} + +const timestamp = () => { + const now = new Date(); + const two = n => String(n).padStart(2, '0'); + return `${now.getFullYear()}-${two(now.getMonth() + 1)}-${two(now.getDate())} ${two(now.getHours())}:${two(now.getMinutes())}`; +}; + +/** The list as lines: one model and its link each. */ +function sourceLines(models, link) { + return models.map((m, idx) => { + const source = m.url || (foundationModelType(m) ? t('modelSourcesStatusUnfilledOptional') : t('modelSourcesStatusUnfilled')); + return link(m, idx, source); + }); +} + +/** A Note node on the canvas listing the workflow's models and links, above the others. */ +export function createCanvasNoteNode(models) { + const creator = globalThis.LiteGraph?.createNode; + const noteNode = models.length && typeof creator === 'function' ? creator.call(globalThis.LiteGraph, 'Note') : null; + if (!noteNode) return null; + const lines = sourceLines(models, (m, idx, source) => `[${idx + 1}] ${m.nodeTitle || m.nodeType || 'Model'}\n ${t('modelSourcesNoteFile')}: ${m.filename}\n ${t('modelSourcesNoteLink')}: ${source}`); + const text = `${t('modelSourcesNoteTitle')}\n${timestamp()} · ${t('modelSourcesNoteCount', { count: models.length })}\n\n${lines.join('\n\n')}`; + noteNode.title = t('modelSourcesNoteTitle'); + if (noteNode.widgets?.length) noteNode.widgets[0].value = text; + let minX = Infinity; + let minY = Infinity; + for (const n of app.graph._nodes || []) { + if (!n.pos) continue; + minX = Math.min(minX, n.pos[0]); + minY = Math.min(minY, n.pos[1]); + } + if (!Number.isFinite(minX)) { minX = 100; minY = 100; } + noteNode.pos = [minX, minY - 320]; + noteNode.size = [480, 260]; + app.graph.add(noteNode); + app.canvas?.setDirty(true, true); + recordCanvasStep(app); + return noteNode; +} + +/** The list as Markdown on the clipboard. */ +export async function copySourcesSummary(models) { + const lines = sourceLines(models, (m, idx, source) => `${idx + 1}. **${m.nodeTitle || m.type || ''}** \`${m.basename || m.filename}\` — ${source}`); + await navigator.clipboard.writeText(`### ${t('modelSourcesNoteTitle')}\n\n${lines.join('\n')}\n`); +} + +export function openExternalUrl(rawUrl) { + const normalized = normalizeUrl(rawUrl); + if (normalized) window.open(normalized, '_blank', 'noopener,noreferrer'); +} + +/** A link for a model without one: its local information, else Civitai by hash; '' when none. */ +export async function autoDetectModelSource(item) { + if (foundationModelType(item)) { + await resolveWorkflowModelsMetadata([item]); + return item.url || ''; + } + try { + const queryPath = item.filename || item.basename; + const res = await fetch('/anomalous/resolve_paths_to_previews', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ paths: [queryPath, item.basename].filter(Boolean) }), + }); + const data = await jsonResponse(res, 'detect model source local'); + const resolved = data?.models?.[queryPath] || data?.models?.[item.basename]; + if (resolved) { + Object.assign(item, { type: resolved.type, subfolder: resolved.subfolder, path_idx: resolved.path_idx }); + if (resolved.metadata?.hash) item.hash = resolved.metadata.hash; + const found = usableSourceUrl(resolved.metadata?.source_url) || usableSourceUrl(resolved.metadata?.civitai_url); + if (found) return found; + } + } catch (e) { + // the local lookup failed; Civitai by hash may still know it + } + if (item.hash) { + try { + const res = await fetch(`https://civitai.com/api/v1/model-versions/by-hash/${item.hash}`); + if (res.ok) { + const data = await res.json(); + if (data.modelId) { + const domain = (data.model?.nsfw || data.nsfwLevel > 1) ? 'civitai.red' : 'civitai.com'; + return `https://${domain}/models/${data.modelId}${data.id ? `?modelVersionId=${data.id}` : ''}`; + } + } + } catch (e) { + // no connection: nothing found + } + } + // A search-results page is not a model source. + return ''; +} diff --git a/web/modules/ui_doctor.js b/web/modules/ui_doctor.js index 9a5cbbd..914fba3 100644 --- a/web/modules/ui_doctor.js +++ b/web/modules/ui_doctor.js @@ -9,7 +9,6 @@ import { translate as t } from './locales.js'; import { applyModelFix, checkWorkflowModels, fixWorkflowModels, isProblem, markReplaced, replacedFrom } from './model_check.js'; import { updateDoctorBanner } from './ui_doctor_banner.js'; -import { openModelSourcesModal } from './ui_model_sources.js'; const MODEL_EXT = /\.(safetensors|ckpt|pt|bin|pth|sft)$/i; const ORDER = ['fixable', 'candidate', 'conflict', 'ambiguous', 'missing', 'changed']; @@ -192,7 +191,7 @@ export async function renderDoctorPage(owner, panel, { refresh = false } = {}) { })); } actions.append(button('anomalous-scan-secondary', t('doctorRecheck'), () => renderDoctorPage(owner, panel, { refresh: true })), - button('anomalous-scan-secondary', t('doctorSources'), () => openModelSourcesModal('workflow'))); + button('anomalous-scan-secondary', t('doctorSources'), () => owner.showModelSources('workflow'))); page.append(actions); const verdict = !entries.length ? t('doctorNoModels') : !entries.some(isProblem) ? t('doctorAllHere', { count: entries.length }) diff --git a/web/modules/ui_grid.js b/web/modules/ui_grid.js index 0a7bc46..60860ad 100644 --- a/web/modules/ui_grid.js +++ b/web/modules/ui_grid.js @@ -7,6 +7,7 @@ import { app } from "../../../scripts/app.js"; import { translate } from './locales.js'; import { focusModel, modelListUrl, renderTypeBar } from './ui_model_types.js'; import { renderSourceBadge } from './model_source.js'; +import { renderModelSourcesView } from './ui_model_sources.js'; import { recordCanvasStep } from './canvas_history.js'; const t = (key, params) => translate(key, params); @@ -26,6 +27,13 @@ function modelCardDisplayName(model) { export async function loadModels() { try { if (this._modelLoadController) this._modelLoadController.abort(); + this.modelSources?.controller?.abort(); + if (this.modelView === 'sources') { // the type bar, then where each model is downloaded + stopMediaInContainer(this.grid); + this.grid.replaceChildren(renderTypeBar(this, 0)); + renderModelSourcesView(this, this.grid); + return; + } this._modelRenderGeneration = (this._modelRenderGeneration || 0) + 1; const loadController = new AbortController(); this._modelLoadController = loadController; diff --git a/web/modules/ui_model_sources.js b/web/modules/ui_model_sources.js index f1924c2..3a58461 100644 --- a/web/modules/ui_model_sources.js +++ b/web/modules/ui_model_sources.js @@ -1,815 +1,250 @@ /** - * ui_model_sources.js - * 模型来源统一中控中心 (Model Source Hub) - * - * 核心功能: - * 1. 查看/编辑当前工作流中用到的模型来源链接 (Workflow Scope) - * 2. 盘点本地全部模型库已识别/缺失信息状态 (Library Scope) - * 3. 一键在画布生成原生 ComfyUI Note 便签节点 (显式工作流节点) - * 4. 同步/保存来源元数据至工作流 JSON/PNG extra 字段 (隐式元数据) - * 5. 一键浏览器直达对应发布页 (Civitai / Hugging Face / Liblib / 网盘等) - * 6. 持久化自定义链接至本地模型 .civitai.info (全系统自动回退) + * The Models page's "Sources" view: where each model can be downloaded, for the open + * workflow's models or every model here. Each row's link can be typed or pasted (any site), + * found automatically, opened, and saved (model_source_links.js keeps it in the model's + * information and, for the workflow's models, in the workflow). The workflow's list can go + * on the canvas as a note or to the clipboard. It is drawn in the models grid under the + * type bar while `owner.modelView === 'sources'`; `owner.modelSources` keeps scope, filter + * and search across redraws. */ -import { app } from '../../../scripts/app.js'; import { translate as t } from './locales.js'; -import { text, jsonResponse } from './ui_dom.js'; -import { createViewScope } from './ui_lifecycle.js'; import { showWorkbenchToast } from './ui_prompt_toast.js'; -import { inferModelFolderTypes, isPhysicalRenameProtectedType } from './model_policies.js'; -import { foundationModelType, partitionSourceModels, shapeLibrarySourceModels, usableSourceUrl } from './model_source_data.js'; +import { typeLabel } from './ui_model_types.js'; +import { foundationModelType, partitionSourceModels } from './model_source_data.js'; +import { + autoDetectModelSource, collectWorkflowModels, copySourcesSummary, createCanvasNoteNode, detectPlatform, + fetchAllLibraryModels, keepAllInWorkflow, normalizeUrl, openExternalUrl, resolveWorkflowModelsMetadata, saveModelSource, +} from './model_source_links.js'; -let activeSourcesModalScope = null; +const COMPONENT_LABELS = { + clip: 'modelSourcesTypeTextEncoder', text_encoders: 'modelSourcesTypeTextEncoder', + vae: 'modelSourcesTypeVae', vae_approx: 'modelSourcesTypeVaeApprox', clip_vision: 'modelSourcesTypeVisionEncoder', +}; -export function detectPlatform(url) { - if (!url || typeof url !== 'string') return null; - const clean = url.trim(); - if (!clean) return null; - if (/civitai\.(com|red)/i.test(clean)) return { name: 'Civitai', color: '#38bdf8', bg: 'rgba(56,189,248,0.15)', border: 'rgba(56,189,248,0.4)' }; - if (/huggingface\.co/i.test(clean)) return { name: 'HuggingFace', color: '#fbbf24', bg: 'rgba(251,191,36,0.15)', border: 'rgba(251,191,36,0.4)' }; - if (/liblib/i.test(clean)) return { name: 'LiblibAI', color: '#ec4899', bg: 'rgba(236,72,153,0.15)', border: 'rgba(236,72,153,0.4)' }; - if (/modelscope\.cn/i.test(clean)) return { name: 'ModelScope', color: '#a855f7', bg: 'rgba(168,85,247,0.15)', border: 'rgba(168,85,247,0.4)' }; - if (/github\.com/i.test(clean)) return { name: 'GitHub', color: '#34d399', bg: 'rgba(52,211,153,0.15)', border: 'rgba(52,211,153,0.4)' }; - if (/pan\.baidu|123pan|quark|lanzou/i.test(clean)) return { name: 'CloudNet', color: '#06b6d4', bg: 'rgba(6,182,212,0.15)', border: 'rgba(6,182,212,0.4)' }; - return { name: 'Web Link', color: '#94a3b8', bg: 'rgba(148,163,184,0.15)', border: 'rgba(148,163,184,0.3)' }; +function el(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; } -export function isModelFilename(val) { - return typeof val === 'string' && /\.(safetensors|ckpt|pt|pth|bin|sft|gguf)$/i.test(val); +function button(className, label, onClick) { + const node = el('button', className, label); + node.type = 'button'; + node.onclick = onClick; + return node; } -export function normalizeUrl(url) { - if (!url) return ''; - let trimmed = url.trim(); - if (!trimmed) return ''; - if (!/^https?:\/\//i.test(trimmed)) { - trimmed = 'https://' + trimmed; - } - return trimmed; +function viewState(owner) { + owner.modelSources ||= { scope: 'library', filter: 'all', search: '', workflow: [], library: [], libraryStatus: 'idle', componentsOpen: false, controller: null }; + return owner.modelSources; } -function foundationType(model) { - return foundationModelType(model); +/** Opens the view on the Models page; `scope`: 'workflow' (the open workflow) or 'library'. */ +export function showModelSources(owner, scope = 'library') { + const state = viewState(owner); + Object.assign(state, { scope, filter: 'all', search: '' }); // a fresh look, not the last search + state.libraryStatus = 'idle'; // scans and edits may have changed it since + owner.modelView = 'sources'; + owner.goTo?.('models'); + owner.loadModels(); } -function canSaveLocalSource(model) { - return !model.isMissing && (!foundationType(model) || model.nodeId == null || Boolean(model.file_path)); +function paintSite(badge, url) { + const site = detectPlatform(url); + badge.hidden = !site; + if (!site) return; + badge.textContent = site.name; + Object.assign(badge.style, { color: site.color, background: site.bg, borderColor: site.border }); } -/** 提取当前画布上所有正在使用的模型 */ -export function collectWorkflowModels() { - const models = []; - const seen = new Set(); - const liveNodes = app.graph?._nodes || []; - const savedSources = app.graph?.extra?.anomalous_model_sources || {}; - const savedHashes = app.graph?.extra?.anomalous_hashes || {}; +/** One model: name, where it is, its link and what can be done with it. */ +function renderRow(model) { + const row = el('div', 'anomalous-msrc-row'); + const info = el('div', 'anomalous-msrc-info'); + const name = el('strong', 'anomalous-msrc-name', model.basename || model.filename); + name.title = model.filename; + const where = model.nodeId != null + ? `${model.nodeTitle} #${model.nodeId}` + : [model.type ? typeLabel({ type: model.type }) : '', model.subfolder].filter(Boolean).join(' · '); + info.append(name, el('small', 'anomalous-msrc-where', where)); + const component = foundationModelType(model); + if (component) info.append(el('span', 'anomalous-msrc-tag', `${t(COMPONENT_LABELS[component] || 'modelSourcesTypeVae')} · ${t('modelSourcesStatusUnfilledOptional')}`)); + if (model.isMissing) info.append(el('span', 'anomalous-msrc-tag is-missing', t('modelSourcesStatusMissingOnDisk'))); - for (const node of liveNodes) { - if (!Array.isArray(node.widgets)) continue; - for (const w of node.widgets) { - const val = w.value; - const folderTypes = inferModelFolderTypes(node, w); - // Native component options such as TAESD need not have a file extension. - const componentOption = /^(vae_name|clip_name\d*|clip_vision(?:_name)?|text_encoder(?:_name)?\d*)$/i.test(w.name || '') - && folderTypes.some(isPhysicalRenameProtectedType) - && typeof val === 'string' && val.trim() && val.toLowerCase() !== 'none'; - if (!isModelFilename(val) && !componentOption) continue; + const link = el('div', 'anomalous-msrc-link'); + const site = el('span', 'anomalous-msrc-site'); + const input = el('input', 'anomalous-msrc-input'); + input.type = 'url'; + input.spellcheck = false; + input.placeholder = t('modelSourcesUrlPlaceholder'); + input.value = model.url || ''; + const open = button('anomalous-msrc-btn', t('modelSourcesOpen'), () => openExternalUrl(input.value)); + const find = button('anomalous-msrc-btn', t('modelSourcesAutoDetect'), null); + const save = button('anomalous-msrc-btn is-primary', t('modelSourcesSave'), null); + link.append(site, input, open, find, save); + row.append(info, link); - const dedupeKey = `${node.id}_${val}`; - if (seen.has(dedupeKey)) continue; - seen.add(dedupeKey); - - const basename = val.split(/[/\\]/).pop(); - const nativeValues = w.options?.values; - const isMissing = Array.isArray(nativeValues) - && !nativeValues.some(value => typeof value === 'string' && value.replaceAll('\\', '/') === val.replaceAll('\\', '/')); - const hashObj = savedHashes[dedupeKey] || savedHashes[val] || window.anomalous_hash_cache?.[val] || window.anomalous_hash_cache?.[basename] || {}; - const hash = typeof hashObj === 'string' ? hashObj : (hashObj.hash || ''); - - const existingSource = savedSources[val] || savedSources[basename] || savedSources[dedupeKey]; - let url = usableSourceUrl(typeof existingSource === 'string' ? existingSource : existingSource?.url) - || usableSourceUrl(hashObj?.url) || usableSourceUrl(hashObj?.civitai_url); - - models.push({ - key: dedupeKey, - nodeId: node.id, - nodeTitle: node.title || node.type || `Node #${node.id}`, - nodeType: node.type, - folderTypes, - filename: val, - basename, - isMissing, - hash, - url, - initialUrl: url, - platform: detectPlatform(url), - }); - } - } - return models; -} - -/** 异步盘点本地全部模型库 */ -export async function fetchAllLibraryModels(signal = null) { - const res = await fetch('/anomalous/all_scan_models?limit=0', { signal }); - const payload = await jsonResponse(res, 'load library models'); - return shapeLibrarySourceModels(payload.models, detectPlatform); -} - -/** 在 ComfyUI 画布生成原生 Note 便签节点 */ -export function createCanvasNoteNode(models) { - if (!models.length) return false; - const creator = (typeof LiteGraph !== 'undefined' ? LiteGraph?.createNode : null) || globalThis.LiteGraph?.createNode || (typeof window !== 'undefined' ? window.LiteGraph?.createNode : null); - if (typeof creator !== 'function') return false; - - const noteNode = creator('Note'); - if (!noteNode) return false; - - const now = new Date(); - const timeStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`; - - let textContent = `═══════════════════════════════════════════════════════════════\n`; - textContent += `📋 工作流模型来源与下载直达 (Workflow Models & Sources)\n`; - textContent += `═══════════════════════════════════════════════════════════════\n`; - textContent += `• 生成时间: ${timeStr}\n`; - textContent += `• 模型总数: ${models.length} 个\n`; - textContent += `───────────────────────────────────────────────────────────────\n\n`; - - models.forEach((m, idx) => { - textContent += `[${idx + 1}] ${m.nodeTitle || m.nodeType || 'Model'}\n`; - textContent += ` 文件: ${m.filename}\n`; - if (m.url) { - textContent += ` 来源: ${m.url}\n`; - } else if (foundationType(m)) { - textContent += ` 来源: ${t('modelSourcesStatusUnfilledOptional')}\n`; - } else { - textContent += ` 来源: ⚠️ 未配置来源发布页\n`; - } - if (m.hash) { - textContent += ` 哈希: ${m.hash.slice(0, 16)}...\n`; - } - textContent += `\n`; - }); - - textContent += `───────────────────────────────────────────────────────────────\n`; - textContent += `由 Anomalous Model Browser 模型来源中控中心一键生成`; - - noteNode.title = window.anomalous_browser_lang === 'zh' ? '📋 模型来源清单 (Sources)' : '📋 Model Sources'; - if (Array.isArray(noteNode.widgets) && noteNode.widgets.length > 0) { - noteNode.widgets[0].value = textContent; - } - - // 计算合适坐标(排在画布最上方或靠左侧) - let minX = Infinity; - let minY = Infinity; - for (const n of app.graph._nodes || []) { - if (n.pos) { - if (n.pos[0] < minX) minX = n.pos[0]; - if (n.pos[1] < minY) minY = n.pos[1]; - } - } - if (!Number.isFinite(minX)) { minX = 100; minY = 100; } - noteNode.pos = [minX, minY - 320]; - noteNode.size = [480, 260]; - - app.graph.add(noteNode); - app.canvas?.setDirty(true, true); - return noteNode; -} - -/** 同步来源至工作流元数据 (extra.anomalous_model_sources) */ -export function syncWorkflowSources(models) { - if (!app.graph) return { count: 0, isUpdate: false }; - app.graph.extra ||= {}; - const existing = app.graph.extra.anomalous_model_sources || {}; - const isUpdate = Object.keys(existing).length > 0; - - const updatedMap = { ...existing }; - let savedCount = 0; - - for (const m of models) { - if (!m.url) continue; - const normalized = normalizeUrl(m.url); - updatedMap[m.filename] = { - name: m.filename, - url: normalized, - platform: detectPlatform(normalized)?.name || 'Custom', - nodeId: m.nodeId, - hash: m.hash || '', - updated_at: Date.now(), - }; - m.url = normalized; - m.initialUrl = normalized; - m.platform = detectPlatform(normalized); - savedCount++; - } - - app.graph.extra.anomalous_model_sources = updatedMap; - app.canvas?.setDirty(true, true); - return { count: savedCount, isUpdate }; -} - -/** 复制 Markdown 格式清单 */ -export async function copySourcesSummary(models) { - if (!models.length) return; - let md = `### 📋 工作流模型下载来源清单 (Workflow Models & Sources)\n\n`; - models.forEach((m, idx) => { - const plat = m.platform ? `[${m.platform.name}]` : ''; - md += `${idx + 1}. **${m.nodeTitle || m.nodeType}** \`${m.basename || m.filename}\`\n`; - if (m.url) { - md += ` - 来源链接: ${plat} ${m.url}\n`; - } else if (foundationType(m)) { - md += ` - 来源链接: ${t('modelSourcesStatusUnfilledOptional')}\n`; - } else { - md += ` - 来源链接: ⚠️ 未指定\n`; - } - }); - await navigator.clipboard.writeText(md); -} - -/** 保存单个模型自定义链接到本地 .civitai.info */ -export async function saveSingleModelToLocalSidecar(item, url) { - if (!canSaveLocalSource(item)) throw new Error(t('modelSourcesLocalTargetUnknown')); - const cleanUrl = normalizeUrl(url); - const targetFilename = item.basename || (item.filename ? item.filename.split(/[/\\]/).pop() : ''); - const body = { - filename: targetFilename, - type: item.type || 'checkpoints', - subfolder: item.subfolder || '/', - path_idx: item.path_idx || 0, - custom_source_url: cleanUrl, + const sync = () => { + const dirty = normalizeUrl(input.value) !== normalizeUrl(model.initialUrl || ''); + row.classList.toggle('is-dirty', dirty); + row.classList.toggle('is-empty', !input.value.trim()); + save.hidden = !dirty; + open.disabled = !input.value.trim(); + find.hidden = Boolean(input.value.trim()); + paintSite(site, input.value); }; - const res = await fetch('/anomalous/update_metadata', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - return jsonResponse(res, 'save custom source url'); -} - -/** 一键打开外部链接 */ -export function openExternalUrl(rawUrl) { - const normalized = normalizeUrl(rawUrl); - if (!normalized) return; - window.open(normalized, '_blank', 'noopener,noreferrer'); -} - -/** 异步解析工作流中模型的本地元数据与路径详情 */ -export async function resolveWorkflowModelsMetadata(models, signal = null) { - if (!Array.isArray(models) || !models.length) return false; - const components = models.filter(m => foundationType(m)); - const requestedPaths = models.filter(m => !foundationType(m)).map(m => m.filename); - try { - const res = await fetch('/anomalous/resolve_paths_to_previews', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ paths: requestedPaths }), - signal, - }); - const data = await jsonResponse(res, 'resolve workflow models'); - const modelsMap = data?.models || {}; - const componentModels = {}; - // The endpoint caps contextual lookups at 16; keep large workflows complete. - for (let offset = 0; offset < components.length; offset += 16) { - const response = await fetch('/anomalous/resolve_paths_to_previews', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, signal, - body: JSON.stringify({ paths: [], context_requests: components.slice(offset, offset + 16).map(m => ({ - key: m.key, path: m.relPath || m.filename, folder_types: m.folderTypes || [m.type], exact_only: true, - })) }), - }); - const batch = await jsonResponse(response, 'resolve component sources'); - Object.assign(componentModels, batch.context_models || {}); - } - let hasChanges = false; - - for (const m of models) { - const info = foundationType(m) ? componentModels[m.key] : modelsMap[m.filename] || modelsMap[m.basename]; - if (info) { - m.type = info.type; - m.subfolder = info.subfolder; - m.path_idx = info.path_idx; - m.file_path = info.file_path; - hasChanges = true; - if (foundationType(m)) m.isMissing = false; - if (!m.hash && info.metadata?.hash) { - m.hash = info.metadata.hash; - hasChanges = true; - } - const resolvedUrl = usableSourceUrl(info.metadata?.source_url) - || usableSourceUrl(info.metadata?.civitai_url); - if (!m.url && resolvedUrl) { - m.url = resolvedUrl; - m.initialUrl = resolvedUrl; - m.platform = detectPlatform(resolvedUrl); - hasChanges = true; - } - if (typeof window !== 'undefined' && window.anomalous_hash_cache) { - if (m.filename && info.metadata?.hash) { - window.anomalous_hash_cache[m.filename] = { - hash: info.metadata.hash, - url: resolvedUrl, - }; - } - } - } - } - return hasChanges; - } catch (e) { - if (!signal?.aborted) { - console.warn('[Model Source Hub] Failed to resolve workflow model info', e); - } - return false; - } -} - -// ----------------------------------------------------------------------------- -// UI RENDERERS (Sub-divided into concise blocks <= 50 lines) -// ----------------------------------------------------------------------------- - -function renderModalHeader(headerEl, scope, state, onScopeChange, onClose) { - headerEl.className = 'anomalous-sources-header'; - const topRow = text(headerEl, 'div', '', 'anomalous-sources-top-row'); - - const titleWrap = text(topRow, 'div', '', 'anomalous-sources-title-wrap'); - text(titleWrap, 'h3', t('modelSourcesModalTitle')); - - const closeBtn = text(topRow, 'button', '✕', 'anomalous-sources-close-btn'); - closeBtn.title = t('close') || '关闭'; - closeBtn.onclick = onClose; - - // Scope Tabs (Active Workflow vs All Local Models) - const tabsRow = text(headerEl, 'div', '', 'anomalous-sources-scope-tabs'); - - const wfTab = text(tabsRow, 'button', '', `anomalous-sources-scope-tab${state.scope === 'workflow' ? ' is-active' : ''}`); - wfTab.innerHTML = `🎛️ ${t('modelSourcesScopeWorkflow')} ${state.workflowModels.length}`; - wfTab.onclick = () => onScopeChange('workflow'); - - const libTab = text(tabsRow, 'button', '', `anomalous-sources-scope-tab${state.scope === 'library' ? ' is-active' : ''}`); - const libraryCount = state.libraryStatus === 'ready' ? state.libraryModels.length : '...'; - libTab.innerHTML = `📚 ${t('modelSourcesScopeLibrary')} ${libraryCount}`; - libTab.onclick = () => onScopeChange('library'); -} - -function renderFilterAndSearch(filterBarEl, state, onFilterChange, onSearch) { - filterBarEl.className = 'anomalous-sources-filter-bar'; - - const pillsWrap = text(filterBarEl, 'div', '', 'anomalous-sources-pills'); - const filters = [ - { id: 'all', label: t('modelSourcesFilterAll') }, - { id: 'resolved', label: t('modelSourcesFilterResolved') }, - { id: 'unresolved', label: t('modelSourcesFilterUnresolved') }, - ]; - - filters.forEach(f => { - const pill = text(pillsWrap, 'button', f.label, `anomalous-sources-pill${state.filter === f.id ? ' is-active' : ''}`); - pill.onclick = () => onFilterChange(f.id); - }); - - const searchInput = text(filterBarEl, 'input', '', 'anomalous-sources-search-input'); - searchInput.placeholder = t('modelSourcesSearchPlaceholder'); - searchInput.value = state.searchKeyword; - searchInput.oninput = () => onSearch(searchInput.value.trim().toLowerCase()); -} - -function renderModelTitle(infoCol, m) { - const titleRow = text(infoCol, 'div', '', 'anomalous-source-row-title'); - text(titleRow, 'span', m.nodeTitle || m.type || 'Model', 'anomalous-source-node-tag'); - text(titleRow, 'strong', m.basename || m.filename, 'anomalous-source-model-name'); - - const component = foundationType(m); - if (component) { - const label = { clip: 'modelSourcesTypeTextEncoder', text_encoders: 'modelSourcesTypeTextEncoder', - vae: 'modelSourcesTypeVae', vae_approx: 'modelSourcesTypeVaeApprox', clip_vision: 'modelSourcesTypeVisionEncoder' }[component]; - text(titleRow, 'span', t(label), 'anomalous-source-badge-neutral'); - } - const sourceBadge = text(titleRow, 'span', t(component ? 'modelSourcesStatusUnfilledOptional' : 'modelSourcesStatusUnfilled'), 'anomalous-source-badge-neutral'); - sourceBadge.hidden = Boolean(m.url?.trim()); - - if (m.isMissing) { - const missingBadge = text(titleRow, 'span', t('modelSourcesStatusMissingOnDisk'), 'anomalous-source-badge-danger'); - missingBadge.title = t('modelSourcesStatusMissingOnDisk'); - } - - if (m.isEditing) { - const editBadge = text(titleRow, 'span', window.anomalous_browser_lang === 'zh' ? '✏️ 正在编辑' : '✏️ Editing', 'anomalous-source-edit-badge'); - editBadge.title = window.anomalous_browser_lang === 'zh' ? '当前处于来源链接修改状态' : 'In editing mode'; - } - - if (m.hash) { - text(titleRow, 'code', `SHA: ${m.hash.slice(0, 10)}...`, 'anomalous-source-hash-chip'); - } - return sourceBadge; -} - -function renderModelUrlInput(inputWrap, m, onRefresh, getActions) { - const isLocked = Boolean(m.url && !m.isEditing); - const inputClass = `anomalous-source-url-input${isLocked ? ' is-locked' : ''}${m.isEditing ? ' is-editing' : ''}`; - const urlInput = text(inputWrap, 'input', '', inputClass); - urlInput.placeholder = t('modelSourcesUrlPlaceholder'); - urlInput.value = m.url || ''; - urlInput.readOnly = isLocked; - - const platBadge = text(inputWrap, 'span', m.platform?.name || '', 'anomalous-source-plat-badge'); - if (m.platform) { - platBadge.style.color = m.platform.color; - platBadge.style.borderColor = m.platform.border; - platBadge.style.backgroundColor = m.platform.bg; - } else { - platBadge.style.display = 'none'; - } - - if (isLocked) { - urlInput.title = window.anomalous_browser_lang === 'zh' - ? '当前为展示锁定态。双击或点击右侧【✏️ 修改】可解锁编辑' - : 'Protected link. Double-click or click [✏️ Edit] to modify'; - urlInput.ondblclick = () => { - m.isEditing = true; - onRefresh(); - }; - } else { - urlInput.oninput = () => { - m.url = urlInput.value.trim(); - m.platform = detectPlatform(m.url); - if (m.platform) { - platBadge.style.display = ''; - platBadge.textContent = m.platform.name; - platBadge.style.color = m.platform.color; - platBadge.style.borderColor = m.platform.border; - platBadge.style.backgroundColor = m.platform.bg; - } else { - platBadge.style.display = 'none'; - } - const { localBtn, jumpBtn, sourceBadge } = getActions(); - if (sourceBadge) sourceBadge.hidden = Boolean(m.url); - const isDirty = Boolean(m.url && m.url.trim() !== (m.initialUrl || '').trim()); - if (localBtn) localBtn.style.display = (isDirty && canSaveLocalSource(m)) ? '' : 'none'; - if (jumpBtn) jumpBtn.disabled = !m.url; - }; - urlInput.onkeydown = (e) => { - if (e.key === 'Escape') { - m.url = m.initialUrl; - m.platform = detectPlatform(m.url); - m.isEditing = false; - onRefresh(); - } else if (e.key === 'Enter') { - m.isEditing = false; - onRefresh(); - } - }; - if (m.isEditing) { - setTimeout(() => { - urlInput.focus(); - urlInput.select(); - }, 0); - } - } - - return urlInput; -} - -function renderModelRowActions(actionsRow, m, state, urlInput, onRefresh, onAutoDetect, onSaveLocal) { - const isDirty = Boolean(m.url && m.url.trim() !== (m.initialUrl || '').trim()); - - const jumpBtn = text(actionsRow, 'button', t('modelSourcesJump'), 'anomalous-btn-primary anomalous-btn-sm anomalous-btn-jump'); - jumpBtn.disabled = !m.url; - jumpBtn.title = window.anomalous_browser_lang === 'zh' ? '在浏览器新标签页中打开对应网址' : 'Open website in new tab'; - jumpBtn.onclick = () => openExternalUrl(m.url); - - const detectBtn = text(actionsRow, 'button', t('modelSourcesAutoDetect'), 'anomalous-btn-ghost anomalous-btn-sm'); - detectBtn.title = window.anomalous_browser_lang === 'zh' ? '从本地元数据或 SHA256 自动解析来源' : 'Auto detect from hash or metadata'; - detectBtn.onclick = () => onAutoDetect(m, urlInput); - - let editBtn = null; - let cancelBtn = null; - - if (m.isEditing) { - cancelBtn = text(actionsRow, 'button', window.anomalous_browser_lang === 'zh' ? '✕ 取消' : '✕ Cancel', 'anomalous-btn-ghost anomalous-btn-sm'); - cancelBtn.title = window.anomalous_browser_lang === 'zh' ? '放弃本次修改并还原' : 'Cancel edits and revert'; - cancelBtn.onclick = () => { - m.url = m.initialUrl; - m.platform = detectPlatform(m.url); - m.isEditing = false; - onRefresh(); - }; - } else if (m.url) { - editBtn = text(actionsRow, 'button', window.anomalous_browser_lang === 'zh' ? '✏️ 修改' : '✏️ Edit', 'anomalous-btn-ghost anomalous-btn-sm'); - editBtn.title = window.anomalous_browser_lang === 'zh' ? '解锁输入框以修改来源网址' : 'Unlock to edit URL'; - editBtn.onclick = () => { - m.isEditing = true; - onRefresh(); - }; - } - - let localBtn = null; - if (canSaveLocalSource(m)) { - localBtn = text(actionsRow, 'button', t('modelSourcesSaveLocal'), 'anomalous-btn-primary anomalous-btn-sm anomalous-btn-save-local'); - localBtn.title = window.anomalous_browser_lang === 'zh' ? '检测到链接已修改,点击记入本地模型的 .civitai.info' : 'Modified link detected. Save to local .civitai.info'; - localBtn.style.display = isDirty ? '' : 'none'; - localBtn.onclick = () => onSaveLocal(m); - } - - return { jumpBtn, detectBtn, editBtn, cancelBtn, localBtn }; -} - -function renderModelRow(listEl, m, state, onRefresh, onAutoDetect, onSaveLocal) { - const card = text(listEl, 'div', '', `anomalous-source-row-item${m.url ? ' has-url' : ' is-missing-url'}`); - - const infoCol = text(card, 'div', '', 'anomalous-source-col-info'); - const sourceBadge = renderModelTitle(infoCol, m); - - const inputRow = text(card, 'div', '', 'anomalous-source-col-input-row'); - const inputWrap = text(inputRow, 'div', '', 'anomalous-source-input-wrap'); - const actionsRow = text(inputRow, 'div', '', 'anomalous-source-row-actions'); - - let actionRefs = {}; - const urlInput = renderModelUrlInput(inputWrap, m, onRefresh, () => ({ ...actionRefs, sourceBadge })); - actionRefs = renderModelRowActions(actionsRow, m, state, urlInput, onRefresh, onAutoDetect, onSaveLocal); -} - -function renderComponentDisclosure(listEl, state, group, renderRow) { - const button = text(listEl, 'button', '', 'anomalous-sources-components-toggle'); - button.setAttribute('aria-expanded', String(state.componentsExpanded)); - const marker = state.componentsExpanded ? '▾' : '▸'; - const label = text(button, 'span', '', 'anomalous-sources-components-label'); - text(label, 'span', `${marker} ${t('modelSourcesComponentsAdvanced', { count: group.componentModels.length })}`); - text(label, 'small', t('modelSourcesComponentsOptionalHint')); - if (group.componentMissingCount) { - text(button, 'span', t('modelSourcesComponentsMissing', { count: group.componentMissingCount }), 'anomalous-source-badge-danger'); - } - button.onclick = () => { - state.componentsExpanded = !state.componentsExpanded; - state.refresh(); - }; - if (!state.componentsExpanded) return; - const rows = text(listEl, 'div', '', 'anomalous-sources-components-list'); - group.componentModels.forEach(model => renderRow(rows, model)); - if (!group.componentModels.length) { - text(rows, 'div', t('modelSourcesComponentsNoSearchMatch'), 'anomalous-sources-components-empty'); - } -} - -function renderFooterBar(footerEl, state, activeMainModels, onSaveWorkflow, onGenerateNote, onCopySummary) { - footerEl.className = 'anomalous-sources-footer'; - footerEl.replaceChildren(); - - const statsEl = text(footerEl, 'div', '', 'anomalous-sources-footer-stats'); - const withUrlCount = activeMainModels.filter(m => Boolean(m.url?.trim())).length; - statsEl.innerHTML = `${t('modelSourcesMainStats')}: ${withUrlCount} / ${activeMainModels.length}`; - - const btnsWrap = text(footerEl, 'div', '', 'anomalous-sources-footer-actions'); - - const copyBtn = text(btnsWrap, 'button', t('modelSourcesCopySummary'), 'anomalous-btn-ghost anomalous-btn-sm'); - copyBtn.onclick = onCopySummary; - - if (state.scope === 'workflow') { - const noteBtn = text(btnsWrap, 'button', t('modelSourcesGenerateNoteNode'), 'anomalous-btn-ghost anomalous-btn-sm'); - noteBtn.title = window.anomalous_browser_lang === 'zh' ? '在 ComfyUI 画布生成一个原生 Note 便签节点,任何人打开都能直接看到下载链接' : 'Generate native Note node with source links on canvas'; - noteBtn.onclick = onGenerateNote; - - // Dynamic Save vs Sync text - const hasExisting = Boolean(Object.keys(app.graph?.extra?.anomalous_model_sources || {}).length); - const saveLabel = hasExisting ? t('modelSourcesSyncToWorkflow') : t('modelSourcesSaveToWorkflow'); - const saveBtn = text(btnsWrap, 'button', saveLabel, 'anomalous-btn-primary anomalous-btn-sm'); - saveBtn.onclick = onSaveWorkflow; - } -} - -// ----------------------------------------------------------------------------- -// MAIN MODAL OPENER -// ----------------------------------------------------------------------------- - -export function openModelSourcesModal(initialScope = 'workflow') { - activeSourcesModalScope?.dispose(); - const scope = createViewScope(); - activeSourcesModalScope = scope; - - const overlay = document.createElement('div'); - overlay.className = 'anomalous-model-sources-overlay'; - scope.onDispose(() => { - overlay.remove(); - if (activeSourcesModalScope === scope) activeSourcesModalScope = null; - }); - - const modal = document.createElement('div'); - modal.className = 'anomalous-model-sources-modal'; - overlay.appendChild(modal); - - const state = { - scope: initialScope, - filter: 'all', - searchKeyword: '', - workflowModels: collectWorkflowModels(), - libraryModels: [], - libraryStatus: 'idle', - componentsExpanded: false, - libraryRequestId: 0, - }; - - const headerEl = text(modal, 'header', '', ''); - const filterBarEl = text(modal, 'div', '', ''); - const bodyEl = text(modal, 'div', '', 'anomalous-sources-body'); - const footerEl = text(modal, 'footer', '', ''); - - const ensureLibraryLoaded = async (force = false) => { - if (state.libraryStatus === 'loading') return; - if (!force && state.libraryStatus === 'ready') { - refreshUi(); - return; - } - const requestId = ++state.libraryRequestId; - state.libraryStatus = 'loading'; - refreshUi(); + const commit = async () => { + save.disabled = true; try { - const items = await fetchAllLibraryModels(scope.signal); - if (scope.signal.aborted || requestId !== state.libraryRequestId) return; - state.libraryModels = items; - state.libraryStatus = 'ready'; + const kept = await saveModelSource(model, input.value); + input.value = model.url; + showWorkbenchToast(t(kept.local && kept.workflow ? 'modelSourcesSavedBoth' : kept.local ? 'modelSourcesSavedLocal' : 'modelSourcesSavedWorkflow')); } catch (error) { - if (scope.signal.aborted || requestId !== state.libraryRequestId) return; - console.error('[Model Source Hub] Failed to fetch library models', error); - state.libraryStatus = 'error'; + showWorkbenchToast(t('modelSourcesSaveFailed', { error: error.message })); + } finally { + save.disabled = false; + sync(); } - if (!scope.signal.aborted && requestId === state.libraryRequestId) refreshUi(); }; - - const refreshUi = () => { - headerEl.replaceChildren(); - filterBarEl.replaceChildren(); - bodyEl.replaceChildren(); - - renderModalHeader(headerEl, scope, state, (newScope) => { - if (state.scope === newScope) return; - state.scope = newScope; - if (newScope === 'library') { - ensureLibraryLoaded(); - return; - } - refreshUi(); - }, () => scope.dispose()); - - renderFilterAndSearch(filterBarEl, state, (newFilter) => { - state.filter = newFilter; - refreshUi(); - }, (keyword) => { - state.searchKeyword = keyword; - refreshUi(); - }); - - const rawList = state.scope === 'workflow' ? state.workflowModels : state.libraryModels; - const group = partitionSourceModels(rawList, state.filter, state.searchKeyword); - const renderRow = (parent, model) => renderModelRow(parent, model, state, - () => refreshUi(), - async (item, inputEl) => { - inputEl.disabled = true; - try { - const detected = await autoDetectModelSource(item); - if (detected) { - item.url = detected; - item.initialUrl = item.initialUrl || detected; - item.platform = detectPlatform(detected); - inputEl.value = detected; - if (state.filter === 'unresolved') state.filter = 'resolved'; - refreshUi(); - showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? '✓ 已识别模型来源!' : '✓ Model source detected!'); - } else { - showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? '未能在本地或云端匹配到官方页面' : 'No online source matched'); - } - } finally { - inputEl.disabled = false; - } - }, - async (item) => { - try { - await saveSingleModelToLocalSidecar(item, item.url); - item.initialUrl = item.url; - item.isEditing = false; - refreshUi(); - showWorkbenchToast(t('modelSourcesSavedLocal')); - } catch (e) { - showWorkbenchToast(window.anomalous_browser_lang === 'zh' ? '保存至本地失败' : 'Failed to save local'); - } - }); - - if (state.scope === 'library' && state.libraryStatus === 'loading') { - const loadingBox = text(bodyEl, 'div', '', 'anomalous-sources-empty'); - text(loadingBox, 'div', `⏳ ${t('modelSourcesLibraryLoading')}`); - } else if (state.scope === 'library' && state.libraryStatus === 'error') { - const errorBox = text(bodyEl, 'div', '', 'anomalous-sources-empty'); - text(errorBox, 'div', t('modelSourcesLibraryLoadFailed')); - const retry = text(errorBox, 'button', t('modelSourcesRetry'), 'anomalous-btn-ghost anomalous-btn-sm'); - retry.onclick = () => { - state.libraryStatus = 'idle'; - ensureLibraryLoaded(); - }; - } else { - if (state.scope === 'library') { - text(bodyEl, 'div', t('modelSourcesLibraryRangeHint'), 'anomalous-sources-library-hint'); - } - group.mainModels.forEach(model => renderRow(bodyEl, model)); - if (group.allComponentCount) { - state.refresh = refreshUi; - renderComponentDisclosure(bodyEl, state, group, renderRow); - } + input.oninput = () => { model.url = input.value.trim(); sync(); }; + input.onkeydown = (event) => { + if (event.key === 'Enter' && !save.hidden) commit(); + if (event.key === 'Escape') { + event.stopPropagation(); // the panel stays open + input.value = model.initialUrl || ''; + model.url = input.value; + sync(); } - - const libraryUnavailable = state.scope === 'library' - && (state.libraryStatus === 'loading' || state.libraryStatus === 'error'); - if (!libraryUnavailable - && !group.mainModels.length && !group.allComponentCount) { - const emptyBox = text(bodyEl, 'div', '', 'anomalous-sources-empty'); - emptyBox.innerHTML = ` -
🔍
-
${t('modelSourcesNoModelsFound')}
- `; - } - - const visibleModels = [...group.mainModels, ...(state.componentsExpanded ? group.componentModels : [])]; - renderFooterBar(footerEl, state, group.mainModels, - () => { - const res = syncWorkflowSources(state.workflowModels); - showWorkbenchToast(res.isUpdate ? t('modelSourcesSyncedToWorkflow') : t('modelSourcesSavedToWorkflow')); - refreshUi(); - }, - () => { - const ok = createCanvasNoteNode(state.workflowModels); - if (ok) showWorkbenchToast(t('modelSourcesNoteCreated')); - }, - async () => { - await copySourcesSummary(visibleModels); - showWorkbenchToast(t('modelSourcesCopied')); - } - ); }; - - refreshUi(); - - resolveWorkflowModelsMetadata(state.workflowModels, scope.signal).then(hasChanges => { - if (hasChanges && !scope.signal.aborted) { - refreshUi(); - } - }); - - overlay.onclick = (e) => { - if (e.target === overlay) scope.dispose(); - }; - - scope.listen(window, 'keydown', (e) => { - if (e.key === 'Escape') scope.dispose(); - }); - - document.body.appendChild(overlay); - if (initialScope === 'library') ensureLibraryLoaded(); - return () => scope.dispose(); -} - -/** 自动识别单条模型来源 */ -export async function autoDetectModelSource(item) { - if (item.url) return item.url; - if (foundationType(item)) { - await resolveWorkflowModelsMetadata([item]); - return item.url || ''; - } - - // 1. Priority: check local sidecar & metadata via backend resolver - try { - const queryPath = item.filename || item.basename; - const res = await fetch('/anomalous/resolve_paths_to_previews', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ paths: [queryPath, item.basename].filter(Boolean) }), - }); - const data = await jsonResponse(res, 'detect model source local'); - const resolved = data?.models?.[queryPath] || data?.models?.[item.basename]; - if (resolved) { - item.type = resolved.type; - item.subfolder = resolved.subfolder; - item.path_idx = resolved.path_idx; - if (resolved.metadata?.hash) item.hash = resolved.metadata.hash; - const foundUrl = usableSourceUrl(resolved.metadata?.source_url) - || usableSourceUrl(resolved.metadata?.civitai_url); - if (foundUrl) return foundUrl; - } - } catch (e) { - // Backend lookup failed, proceed to cloud/search fallback - } - - // 2. Cloud lookup if hash exists - if (item.hash) { + save.onclick = commit; + find.onclick = async () => { + find.disabled = true; try { - const res = await fetch(`https://civitai.com/api/v1/model-versions/by-hash/${item.hash}`); - if (res.ok) { - const data = await res.json(); - if (data.modelId) { - const domain = (data.model?.nsfw || data.nsfwLevel > 1) ? 'civitai.red' : 'civitai.com'; - return `https://${domain}/models/${data.modelId}${data.id ? '?modelVersionId=' + data.id : ''}`; - } + const found = await autoDetectModelSource(model); + if (found) { + input.value = found; + model.url = found; } - } catch (e) { - // Network fallback + showWorkbenchToast(t(found ? 'modelSourcesFound' : 'modelSourcesNotFound')); + } finally { + find.disabled = false; + sync(); } + }; + sync(); + return row; +} + +async function loadLibrary(state, signal, draw) { + if (state.libraryStatus !== 'idle') return; + state.libraryStatus = 'loading'; + draw(); + try { + state.library = await fetchAllLibraryModels(signal); + state.libraryStatus = 'ready'; + } catch (error) { + if (signal.aborted) return; + console.warn('[AMB] Model sources: the library did not load.', error); + state.libraryStatus = 'error'; + } + draw(); +} + +function renderBar(state, draw, setScope) { + const bar = el('div', 'anomalous-msrc-bar'); + const scopes = el('div', 'anomalous-msrc-pills'); + const libraryCount = state.libraryStatus === 'ready' ? state.library.length : '…'; + for (const [id, label] of [['workflow', `${t('modelSourcesScopeWorkflow')} · ${state.workflow.length}`], ['library', `${t('modelSourcesScopeLibrary')} · ${libraryCount}`]]) { + scopes.append(button(`anomalous-msrc-pill${state.scope === id ? ' is-active' : ''}`, label, () => setScope(id))); + } + const filters = el('div', 'anomalous-msrc-pills'); + for (const [id, key] of [['all', 'modelSourcesFilterAll'], ['resolved', 'modelSourcesFilterResolved'], ['unresolved', 'modelSourcesFilterUnresolved']]) { + filters.append(button(`anomalous-msrc-pill${state.filter === id ? ' is-active' : ''}`, t(key), () => { state.filter = id; draw(); })); + } + const search = el('input', 'anomalous-msrc-search'); + search.type = 'search'; + search.placeholder = t('modelSourcesSearchPlaceholder'); + search.value = state.search; + search.oninput = () => { state.search = search.value.trim().toLowerCase(); draw({ keepSearchFocus: true }); }; + bar.append(scopes, filters, search); + return bar; +} + +function renderFoot(state, shown, main) { + const foot = el('div', 'anomalous-msrc-foot'); + const done = main.filter(model => model.url).length; + foot.append(el('span', 'anomalous-msrc-stats', t('modelSourcesStats', { done, total: main.length }))); + const actions = el('div', 'anomalous-msrc-actions'); + actions.append(button('anomalous-msrc-btn', t('modelSourcesCopySummary'), async () => { + await copySourcesSummary(shown); + showWorkbenchToast(t('modelSourcesCopied')); + })); + if (state.scope === 'workflow') { + actions.append(button('anomalous-msrc-btn', t('modelSourcesGenerateNoteNode'), () => { + if (createCanvasNoteNode(state.workflow)) showWorkbenchToast(t('modelSourcesNoteCreated')); + }), button('anomalous-msrc-btn', t('modelSourcesKeepAll'), () => { + showWorkbenchToast(t('modelSourcesKeptAll', { count: keepAllInWorkflow(state.workflow) })); + })); } + foot.append(actions); + return foot; +} + +function renderList(state, draw) { + const list = el('div', 'anomalous-msrc-list'); + if (state.scope === 'library' && state.libraryStatus !== 'ready') { + list.append(el('p', 'anomalous-msrc-empty', t(state.libraryStatus === 'error' ? 'modelSourcesLibraryLoadFailed' : 'modelSourcesLibraryLoading'))); + if (state.libraryStatus === 'error') list.append(button('anomalous-msrc-btn', t('modelSourcesRetry'), () => { state.libraryStatus = 'idle'; draw({ reload: true }); })); + return { list, shown: [], main: [] }; + } + const models = state.scope === 'workflow' ? state.workflow : state.library; + const group = partitionSourceModels(models, state.filter, state.search); + list.append(...group.mainModels.map(renderRow)); + if (group.allComponentCount) { + const toggle = button('anomalous-msrc-components', `${state.componentsOpen ? '▾' : '▸'} ${t('modelSourcesComponentsAdvanced', { count: group.componentModels.length })} · ${t('modelSourcesComponentsOptionalHint')}`, + () => { state.componentsOpen = !state.componentsOpen; draw(); }); + toggle.setAttribute('aria-expanded', String(state.componentsOpen)); + list.append(toggle); + if (state.componentsOpen) list.append(...group.componentModels.map(renderRow)); + } + if (!group.mainModels.length && !group.allComponentCount) { + list.append(el('p', 'anomalous-msrc-empty', t(state.scope === 'workflow' && !models.length ? 'modelSourcesWorkflowEmpty' : 'modelSourcesNoModelsFound'))); + } + const shown = [...group.mainModels, ...(state.componentsOpen ? group.componentModels : [])]; + return { list, shown, main: models.filter(model => !foundationModelType(model)) }; +} + +/** Draws the view into the models grid (after the type bar). */ +export function renderModelSourcesView(owner, grid) { + const state = viewState(owner); + state.controller?.abort(); + const controller = new AbortController(); + state.controller = controller; + state.workflow = collectWorkflowModels(); + const view = el('section', 'anomalous-msrc'); + grid.appendChild(view); - // A search-results page is not a model source. Keep the item unresolved - // unless local metadata or an exact hash lookup identifies a release. - return ''; + const draw = ({ keepSearchFocus = false, reload = false } = {}) => { + if (controller.signal.aborted || !view.isConnected) return; + if (reload) loadLibrary(state, controller.signal, draw); + const { list, shown, main } = renderList(state, draw); + const head = el('div', 'anomalous-msrc-head'); + head.append(el('h2', 'anomalous-msrc-title', t('modelSourcesTitle')), el('p', 'anomalous-msrc-lead', t('modelSourcesLead'))); + const bar = renderBar(state, draw, (scope) => { + state.scope = scope; + if (scope === 'library') loadLibrary(state, controller.signal, draw); + draw(); + }); + view.replaceChildren(head, bar, list, renderFoot(state, shown, main)); + if (keepSearchFocus) { + const search = bar.querySelector('.anomalous-msrc-search'); + search.focus(); + search.setSelectionRange(search.value.length, search.value.length); + } + }; + draw(); + resolveWorkflowModelsMetadata(state.workflow, controller.signal).then(changed => { if (changed) draw(); }); + if (state.scope === 'library') loadLibrary(state, controller.signal, draw); } diff --git a/web/modules/ui_model_types.js b/web/modules/ui_model_types.js index e37c37e..fecdd4f 100644 --- a/web/modules/ui_model_types.js +++ b/web/modules/ui_model_types.js @@ -7,10 +7,11 @@ * `owner.modelScope` is what the grid lists: `{type, path_idx}` for a chip, plus * `subfolder` for a list folder. `currentType/PathIdx/Subfolder` stay "where the model at * hand lives", which the editor, the scanner and "add to canvas" read (see focusModel). + * The last chip, Sources, shows where each model is downloaded instead of the cards + * (`owner.modelView === 'sources'`, ui_model_sources.js); a type chip goes back. */ import { translate as t } from './locales.js'; -import { openModelSourcesModal } from './ui_model_sources.js'; const SCOPE_KEY = 'anomalous_model_scope'; @@ -49,6 +50,7 @@ function showType(owner, group) { } function openType(owner, group) { + owner.modelView = ''; showType(owner, group); owner.renderSidebar(); owner.loadModels(); @@ -94,8 +96,9 @@ export function renderTypeBar(owner, listed) { bar.setAttribute('aria-label', t('modelTypesLabel')); const scope = owner.modelScope; const groups = owner.foldersData || []; + const inSources = owner.modelView === 'sources'; for (const group of groups) { - const active = sameGroup(group, scope); + const active = !inSources && sameGroup(group, scope); const whole = active && !scope.subfolder; // The folder counts date from the last folder load; the listed type counts what it shows. const count = whole ? listed : modelCount(group); @@ -114,7 +117,7 @@ export function renderTypeBar(owner, listed) { chip.onclick = () => { if (!whole) openType(owner, group); }; bar.appendChild(chip); } - const group = scope?.subfolder && groups.find(item => sameGroup(item, scope)); + const group = !inSources && scope?.subfolder && groups.find(item => sameGroup(item, scope)); if (group) { const crumb = document.createElement('button'); crumb.type = 'button'; @@ -131,13 +134,15 @@ export function renderTypeBar(owner, listed) { crumb.onclick = () => openType(owner, group); bar.appendChild(crumb); } - // Where each model can be downloaded (the Model Source Hub, library scope). + // Where each model can be downloaded, in place of the cards. const sources = document.createElement('button'); sources.type = 'button'; sources.className = 'anomalous-model-type-chip is-tool'; + sources.classList.toggle('is-active', inSources); + sources.setAttribute('aria-pressed', String(inSources)); sources.textContent = t('toolModelSourcesShort'); sources.title = t('toolModelSourcesTitle'); - sources.onclick = () => openModelSourcesModal('library'); + sources.onclick = () => { if (!inSources) owner.showModelSources('library'); }; bar.appendChild(sources); return bar; } diff --git a/web/modules/ui_sidebar.js b/web/modules/ui_sidebar.js index b949316..2ca73ad 100644 --- a/web/modules/ui_sidebar.js +++ b/web/modules/ui_sidebar.js @@ -402,6 +402,7 @@ export function renderSidebar() { return; } this.modelScope = { type: typeGroup.type, path_idx: typeGroup.path_idx, subfolder: path }; + this.modelView = ''; this.currentType = typeGroup.type; this.currentPathIdx = typeGroup.path_idx; this.currentSubfolder = path; diff --git a/web/styles.css b/web/styles.css index 71b7353..d85a747 100644 --- a/web/styles.css +++ b/web/styles.css @@ -8,7 +8,7 @@ @import url("./styles/07-prompt-studio.css?v=20261002-theme-1"); @import url("./styles/08-theme-gallery-overrides.css?v=20261002-theme-1"); @import url("./styles/09-prompt-drawers.css?v=20261002-theme-1"); -@import url("./styles/10-model-sources.css?v=20261002-theme-1"); +@import url("./styles/10-model-sources.css?v=20261002-sources-1"); @import url("./styles/11-audio-studio.css?v=20261002-theme-1"); @import url("./styles/12-tts-setup.css?v=20261002-theme-1"); @import url("./styles/13-script-director.css?v=20261002-theme-1"); diff --git a/web/styles/10-model-sources.css b/web/styles/10-model-sources.css index 73a2862..ee8b807 100644 --- a/web/styles/10-model-sources.css +++ b/web/styles/10-model-sources.css @@ -1,429 +1,263 @@ -/* Model Source Hub Modal (模型来源统一中控中心) */ -/* ========================================================================= */ -.anomalous-model-sources-overlay { - position: fixed; - inset: 0; - background: rgba(5, 7, 12, 0.78); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); - z-index: 1000008; - display: flex; - align-items: center; - justify-content: center; - padding: 20px; - box-sizing: border-box; - animation: anmFadeIn 0.2s ease; -} - -.anomalous-model-sources-modal { - width: 880px; - max-width: 96vw; - max-height: 88vh; - background: var(--amb-bg-page); - border: 1px solid rgba(244, 63, 94, 0.35); - border-radius: 12px; - box-shadow: 0 24px 60px rgba(0, 0, 0, 0.85), 0 0 32px rgba(190, 20, 55, 0.18); +/* Model Sources: the Models page's view of where each model is downloaded (ui_model_sources.js). */ +.anomalous-msrc { + grid-column: 1 / -1; display: flex; flex-direction: column; - overflow: hidden; + gap: 14px; + min-width: 0; + padding: 4px 2px 12px; + container-type: inline-size; color: var(--amb-text-main); } -.anomalous-sources-header { - padding: 14px 18px 0; - border-bottom: 1px solid var(--amb-border); - background: rgba(18, 20, 28, 0.9); - flex-shrink: 0; +.anomalous-msrc-title { + margin: 0 0 4px; + font-size: 1.15rem; + font-weight: 600; } -.anomalous-sources-top-row { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 12px; -} - -.anomalous-sources-title-wrap h3 { +.anomalous-msrc-lead { + max-width: 760px; margin: 0; - font-size: 1.05rem; - font-weight: 600; - color: var(--amb-text-main); -} - -.anomalous-sources-close-btn { - background: transparent; - border: none; color: var(--amb-text-muted); - font-size: 1.1rem; - cursor: pointer; - padding: 4px 8px; - border-radius: 6px; - transition: all 0.15s ease; + font-size: 0.85rem; + line-height: 1.5; } -.anomalous-sources-close-btn:hover { - color: var(--amb-text-main); - background: rgba(255, 255, 255, 0.1); -} - -.anomalous-sources-scope-tabs { +.anomalous-msrc-bar { display: flex; - gap: 8px; -} - -.anomalous-sources-scope-tab { - background: transparent; - border: none; - border-bottom: 2px solid transparent; - color: var(--amb-text-muted); - padding: 8px 16px; - font-size: 0.88rem; - font-weight: 500; - cursor: pointer; - transition: all 0.15s ease; - display: flex; - align-items: center; - gap: 6px; -} - -.anomalous-sources-scope-tab:hover { - color: var(--amb-text-main); -} - -.anomalous-sources-scope-tab.is-active { - color: var(--amb-link); - border-bottom-color: var(--amb-link); - font-weight: 600; -} - -.anomalous-sources-badge-num { - font-size: 0.72rem; - padding: 1px 6px; - border-radius: 10px; - background: rgba(255, 255, 255, 0.08); - color: var(--amb-text-soft); -} - -.anomalous-sources-filter-bar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding: 10px 18px; - border-bottom: 1px solid var(--amb-border); - background: rgba(14, 16, 23, 0.95); - flex-shrink: 0; -} - -.anomalous-sources-pills { - display: flex; - gap: 6px; -} - -.anomalous-sources-pill { - background: rgba(255, 255, 255, 0.04); - border: 1px solid var(--amb-border); - color: var(--amb-text-muted); - border-radius: 16px; - padding: 3px 10px; - font-size: 0.78rem; - cursor: pointer; - transition: all 0.15s ease; -} - -.anomalous-sources-pill:hover { - color: var(--amb-text-main); - background: rgba(255, 255, 255, 0.08); -} - -.anomalous-sources-pill.is-active { - background: color-mix(in srgb, var(--amb-link) 18%, transparent); - border-color: color-mix(in srgb, var(--amb-link) 40%, transparent); - color: var(--amb-link); - font-weight: 600; -} - -.anomalous-sources-search-input { - background: rgba(0, 0, 0, 0.3); - border: 1px solid var(--amb-border); - border-radius: 6px; - color: var(--amb-text-main); - font-size: 0.8rem; - padding: 5px 10px; - width: 220px; - outline: none; - transition: border-color 0.15s ease; -} - -.anomalous-sources-search-input:focus { - border-color: var(--amb-link); -} - -.anomalous-sources-body { - flex: 1; - overflow-y: auto; - padding: 14px 18px; - display: flex; - flex-direction: column; - gap: 10px; -} - -.anomalous-source-row-item { - background: rgba(255, 255, 255, 0.03); - border: 1px solid var(--amb-border); - border-radius: 8px; - padding: 10px 14px; - display: flex; - flex-direction: column; - gap: 8px; - transition: border-color 0.15s ease, background 0.15s ease; -} - -.anomalous-source-row-item.has-url { - border-left: 3px solid #10b981; -} - -.anomalous-source-row-item.is-missing-url { - border-left: 3px solid #64748b; -} - -.anomalous-source-row-title { - display: flex; - align-items: center; - gap: 8px; flex-wrap: wrap; -} - -.anomalous-source-node-tag { - font-size: 0.72rem; - padding: 1px 6px; - border-radius: 4px; - background: rgba(244, 63, 94, 0.15); - border: 1px solid rgba(244, 63, 94, 0.3); - color: #fb7185; - font-family: monospace; -} - -.anomalous-source-model-name { - font-size: 0.88rem; - color: var(--amb-text-main); - word-break: break-all; -} - -.anomalous-source-badge-neutral { - font-size: 0.72rem; - padding: 1px 5px; - border-radius: 4px; - background: rgba(148, 163, 184, 0.08); - border: 1px solid rgba(148, 163, 184, 0.2); - color: var(--amb-text-soft); -} - -.anomalous-source-badge-danger { - font-size: 0.7rem; - padding: 1px 5px; - border-radius: 4px; - background: rgba(239, 68, 68, 0.2); - border: 1px solid rgba(239, 68, 68, 0.4); - color: #f87171; -} - -.anomalous-source-hash-chip { - font-size: 0.72rem; - color: var(--amb-text-dim); - margin-left: auto; - font-family: monospace; -} - -.anomalous-source-col-input-row { - display: flex; align-items: center; - gap: 8px; + gap: 8px 12px; } -.anomalous-source-input-wrap { - flex: 1; - position: relative; - display: flex; - align-items: center; -} - -.anomalous-source-url-input { - width: 100%; - background: rgba(0, 0, 0, 0.4); +.anomalous-msrc-pills { + display: inline-flex; + gap: 3px; + padding: 3px; border: 1px solid var(--amb-border); - border-radius: 6px; - color: var(--amb-text-main); - font-size: 0.82rem; - padding: 6px 10px; - padding-right: 96px; - outline: none; - box-sizing: border-box; - font-family: monospace; + border-radius: 999px; + background: var(--amb-bg-input); } -.anomalous-source-url-input:focus { - border-color: var(--amb-link); - background: rgba(0, 0, 0, 0.6); -} - -.anomalous-source-url-input.is-locked { - background: rgba(0, 0, 0, 0.25); - border-color: var(--amb-border); +.anomalous-msrc-pill { + padding: 3px 11px; + border: none; + border-radius: 999px; + background: transparent; color: var(--amb-text-muted); - cursor: default; + font-size: 12px; + cursor: pointer; } -.anomalous-source-url-input.is-locked:hover { - border-color: var(--amb-border-strong); -} - -.anomalous-source-url-input.is-editing { - border-color: #f59e0b; - background: rgba(245, 158, 11, 0.08); - box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.25); +.anomalous-msrc-pill:hover { color: var(--amb-text-main); } -.anomalous-source-edit-badge { - font-size: 0.7rem; - font-weight: 500; - color: #fbbf24; - background: rgba(245, 158, 11, 0.15); - border: 1px solid rgba(245, 158, 11, 0.35); - padding: 1px 6px; - border-radius: 4px; +.anomalous-msrc-pill.is-active { + background: color-mix(in srgb, var(--amb-link) 16%, transparent); + color: var(--amb-link); + font-weight: 600; +} + +.anomalous-msrc-search { + flex: 1; + min-width: 180px; + max-width: 320px; + height: 30px; + box-sizing: border-box; + padding: 0 12px; + border: 1px solid var(--amb-border); + border-radius: 999px; + background: var(--amb-bg-input); + color: var(--amb-text-main); + font-size: 12px; + outline: none; +} + +.anomalous-msrc-search:focus { + border-color: color-mix(in srgb, var(--amb-link) 45%, transparent); +} + +.anomalous-msrc-list { + display: flex; + flex-direction: column; + gap: 6px; +} + +.anomalous-msrc-row { + display: grid; + grid-template-columns: minmax(180px, 1fr) minmax(260px, 1.6fr); + align-items: center; + gap: 8px 16px; + padding: 10px 12px; + border: 1px solid var(--amb-border); + border-radius: var(--amb-radius-card); + background: var(--amb-bg-card); +} + +.anomalous-msrc-row.is-dirty { + border-color: color-mix(in srgb, var(--amb-link) 45%, transparent); +} + +.anomalous-msrc-info { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 8px; + min-width: 0; +} + +.anomalous-msrc-name { + overflow: hidden; + max-width: 100%; + font-size: 0.88rem; + text-overflow: ellipsis; white-space: nowrap; } -.anomalous-btn-save-local { - background: linear-gradient(135deg, #f59e0b, #d97706) !important; - border-color: rgba(245, 158, 11, 0.6) !important; - color: var(--amb-text-main) !important; - font-weight: 600; - animation: anomalousSourcesPulse 1.6s ease-in-out infinite; -} - -@keyframes anomalousSourcesPulse { - 0%, 100% { box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.4); } - 50% { box-shadow: 0 0 0 4px rgba(245, 158, 11, 0); } -} - -.anomalous-source-plat-badge { - position: absolute; - right: 6px; - font-size: 0.7rem; - font-weight: 500; - padding: 2px 7px; - border-radius: 4px; - border: 1px solid; - pointer-events: none; -} - -.anomalous-source-row-actions { - display: flex; - gap: 6px; - align-items: center; - flex-shrink: 0; -} - -.anomalous-btn-jump { - background: linear-gradient(135deg, var(--amb-link), #0369a1); - border: 1px solid color-mix(in srgb, var(--amb-link) 40%, transparent); - color: var(--amb-text-main); -} - -.anomalous-btn-jump:disabled { - opacity: 0.4; - cursor: not-allowed; -} - -.anomalous-sources-footer { - padding: 10px 18px; - border-top: 1px solid var(--amb-border); - background: rgba(18, 20, 28, 0.95); - display: flex; - align-items: center; - justify-content: space-between; - flex-shrink: 0; -} - -.anomalous-sources-footer-stats { - font-size: 0.82rem; - color: var(--amb-text-muted); -} - -.anomalous-sources-footer-actions { - display: flex; - gap: 8px; -} - -.anomalous-sources-empty { - padding: 40px 20px; - text-align: center; - color: var(--amb-text-muted); - font-size: 0.9rem; -} - -.anomalous-sources-library-hint { - padding: 8px 12px; - margin-bottom: 8px; - color: var(--amb-text-secondary, var(--amb-text-muted)); - background: rgba(255, 255, 255, 0.025); - border: 1px solid var(--amb-border); - border-radius: 7px; +.anomalous-msrc-where { + color: var(--amb-text-dim); font-size: 0.78rem; } -.anomalous-sources-components-toggle { - width: 100%; +.anomalous-msrc-tag { + padding: 1px 7px; + border: 1px solid var(--amb-border-strong); + border-radius: 999px; + color: var(--amb-text-muted); + font-size: 0.72rem; +} + +.anomalous-msrc-tag.is-missing { + border-color: rgba(248, 113, 113, 0.45); + color: #f87171; +} + +.anomalous-msrc-link { display: flex; align-items: center; - justify-content: space-between; - flex-wrap: wrap; - gap: 10px; - margin-top: 8px; - padding: 11px 12px; - color: var(--amb-text-secondary, var(--amb-text-soft)); - background: rgba(148, 163, 184, 0.07); - border: 1px solid rgba(148, 163, 184, 0.18); - border-radius: 8px; - cursor: pointer; - font: 600 0.84rem/1.3 system-ui, sans-serif; - text-align: left; + gap: 6px; + min-width: 0; } -.anomalous-sources-components-toggle:hover { +.anomalous-msrc-site { + flex-shrink: 0; + padding: 1px 7px; + border: 1px solid; + border-radius: 999px; + font-size: 0.72rem; +} + +.anomalous-msrc-input { + flex: 1; + min-width: 0; + height: 30px; + box-sizing: border-box; + padding: 0 10px; + border: 1px solid var(--amb-border-strong); + border-radius: var(--amb-radius-control); + background: var(--amb-bg-input); color: var(--amb-text-main); - background: rgba(148, 163, 184, 0.11); + font-size: 12px; + outline: none; } -.anomalous-sources-components-toggle:focus-visible { - outline: 2px solid var(--amb-accent, var(--amb-link)); - outline-offset: 2px; +.anomalous-msrc-input:focus { + border-color: color-mix(in srgb, var(--amb-link) 55%, transparent); } -.anomalous-sources-components-label { - display: flex; - flex-direction: column; - gap: 2px; +.anomalous-msrc-row.is-empty .anomalous-msrc-input:not(:focus) { + border-style: dashed; } -.anomalous-sources-components-label small { - color: var(--amb-text-muted, var(--amb-text-dim)); - font-size: 0.7rem; - font-weight: 400; +.anomalous-msrc-btn { + flex-shrink: 0; + height: 30px; + padding: 0 12px; + border: 1px solid var(--amb-border-strong); + border-radius: var(--amb-radius-control); + background: transparent; + color: var(--amb-text-soft); + font-size: 12px; + white-space: nowrap; + cursor: pointer; } -.anomalous-sources-components-list { - padding: 6px 0 0 14px; - border-left: 1px solid rgba(148, 163, 184, 0.2); +.anomalous-msrc-btn:hover:not(:disabled) { + border-color: var(--amb-border-hover); + background: var(--amb-bg-card-hover); + color: var(--amb-text-main); } -.anomalous-sources-components-empty { - padding: 14px 10px; - color: var(--amb-text-dim); +.anomalous-msrc-btn:disabled { + opacity: 0.45; + cursor: default; +} + +.anomalous-msrc-btn.is-primary { + border-color: transparent; + background: var(--amb-btn-primary-bg); + color: var(--amb-btn-primary-text); + font-weight: 600; +} + +.anomalous-msrc-btn.is-primary:hover:not(:disabled) { + background: var(--amb-btn-primary-hover); + color: var(--amb-btn-primary-text); +} + +.anomalous-msrc-btn[hidden] { + display: none; +} + +.anomalous-msrc-components { + align-self: flex-start; + margin-top: 6px; + padding: 4px 0; + border: none; + background: none; + color: var(--amb-text-muted); font-size: 0.8rem; + cursor: pointer; +} + +.anomalous-msrc-components:hover { + color: var(--amb-text-main); +} + +.anomalous-msrc-empty { + margin: 18px 0; + color: var(--amb-text-dim); + font-size: 0.85rem; +} + +.anomalous-msrc-foot { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px; + padding-top: 12px; + border-top: 1px solid var(--amb-border); +} + +.anomalous-msrc-stats { + color: var(--amb-text-muted); + font-size: 0.82rem; +} + +.anomalous-msrc-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +/* Narrow (docked): the link goes under the name. */ +@container (max-width: 640px) { + .anomalous-msrc-row { + grid-template-columns: 1fr; + } } /* =============================================================================