Migrate frontend sources to TypeScript

This commit is contained in:
Ewald
2026-05-26 23:13:21 +02:00
parent bfaaf29773
commit 2fd101cb15
72 changed files with 11857 additions and 5668 deletions
+1
View File
@@ -33,6 +33,7 @@ htmlcov/
# Build
dist/
build/
node_modules/
*.egg-info/
CHANGELOG.md
docs/AUDIT_REPORT.md
+113
View File
@@ -0,0 +1,113 @@
# AGENTS.md
Guidelines for AI agents working on `ComfyUI-Majoor-ProjectSettings`.
## Mandatory Upstream Research
Before answering implementation questions or changing code in this repository, inspect the relevant upstream ComfyUI projects. Do not rely only on memory.
Use these sources:
- ComfyUI core: https://github.com/Comfy-Org/ComfyUI
- ComfyUI frontend: https://github.com/Comfy-Org/ComfyUI_frontend.git
- ComfyUI Manager: https://github.com/Comfy-Org/ComfyUI-Manager
- ComfyUI Desktop: https://github.com/Comfy-Org/desktop
- Comfy-Org overview: https://github.com/Comfy-Org
For each task, check the source that owns the behavior you are touching:
- Extension lifecycle, `app.registerExtension`, graph hooks, node APIs: check ComfyUI core and frontend.
- Sidebar tabs, settings UI, dialogs, menus, theme tokens, component conventions: check ComfyUI frontend.
- Custom node packaging, installation, Manager metadata, compatibility expectations: check ComfyUI Manager.
- Desktop-specific paths, packaged runtime behavior, filesystem constraints: check ComfyUI Desktop.
- Cross-repo direction, renamed packages, current repository layout: check the Comfy-Org organization.
When upstream behavior matters, mention the upstream files or documented patterns used to justify the change.
## Project Scope
This custom node must stay aligned with ComfyUI instead of inventing a separate app architecture.
The extension currently provides:
- A ComfyUI web extension from `js/`.
- Python server routes under `server/`.
- No custom ComfyUI nodes.
- Project-aware output paths under `output/PROJECTS/<project>/`.
- Workflow save, project signature, missing-model tools, and safe path validation.
Keep that contract unless the user explicitly asks for a breaking redesign.
## Frontend Rules
- Prefer ComfyUI frontend APIs and UI patterns over custom framework assumptions.
- Keep compatibility with the way ComfyUI loads `WEB_DIRECTORY` assets.
- If migrating JavaScript to TypeScript, preserve the generated browser modules expected by ComfyUI.
- Do not add Vue, React, Vite, or another framework unless upstream ComfyUI frontend conventions or the requested feature clearly justify it.
- If Vue is introduced, document why it is compatible with ComfyUI extension loading and how the build output is served.
- Use ComfyUI settings, dialogs, sidebar APIs, and graph hooks when available.
- Keep fallback behavior for older ComfyUI versions when this repo already supports it.
- Avoid global state leaks; namespace globals with `mjr` or `Majoor`.
- Browser code must handle missing or changed ComfyUI APIs defensively.
## Backend Rules
- Use ComfyUI server route patterns from upstream.
- Keep routes small and validate all inputs at the boundary.
- Project paths must remain relative to ComfyUI output/project roots.
- Reject absolute paths, drive paths, traversal (`..`), and unsafe separators.
- Preserve JSON error responses with `{ "ok": false, "error": "..." }`.
- Treat model download, URL fetch, and file write features as security-sensitive.
- Keep CSRF, same-origin, API-key, host allowlist, private-IP, and size-limit protections intact.
## Compatibility Checklist
Before finishing a code change, verify:
- ComfyUI can still discover the extension through `WEB_DIRECTORY`.
- The main entry module still registers with `app.registerExtension`.
- Existing hooks such as `setup`, `beforeConfigureGraph`, `afterConfigureGraph`, `beforeQueuePrompt`, and `nodeCreated` still behave as expected.
- Sidebar registration still falls back to the floating panel if needed.
- Existing API endpoints are not renamed unless the README and callers are updated together.
- Workflow JSON signatures remain backward compatible.
- Existing localStorage keys either remain valid or are migrated.
## Refactor Policy
- Prefer small, reviewable refactors.
- Do not mix a formatting-only rewrite with behavioral changes.
- When converting JS to TS, start with type-safe module boundaries and shared domain types.
- Keep generated files clearly separated from source files if a build step is added.
- Add or update scripts so the repo can rebuild frontend assets reproducibly.
- Do not remove plain JS runtime files until ComfyUI loading and packaging are verified.
## Testing And Verification
Use the smallest verification that proves the change:
- Python validation and route logic: run the relevant `pytest` tests.
- Frontend build changes: run the TypeScript/build command.
- UI behavior changes: test inside ComfyUI when possible.
- Path or security changes: add tests for rejected and accepted inputs.
- Download or network changes: test error paths and size/host guards.
If a verification step cannot be run locally, state exactly what was not run and why.
## Documentation
Update docs when behavior changes:
- `README.md` for user-facing features, installation, settings, and endpoints.
- `docs/project_structure.json` when project folder roles change.
- `AGENTS.md` when development workflow or upstream alignment rules change.
Keep documentation practical. Include paths, commands, and compatibility notes rather than broad intent.
## Response Expectations
When replying to user requests:
- Answer in the user's language when practical.
- Be explicit about which upstream ComfyUI source was checked when it affects the answer.
- Prefer implementing requested changes over only proposing them.
- Surface compatibility risks early, especially around ComfyUI frontend loading, Manager packaging, Desktop paths, and security-sensitive server routes.
+70
View File
@@ -0,0 +1,70 @@
# ComfyUI Integration Audit
Date: 2026-05-26
## Upstream Sources Checked
- `Comfy-Org/ComfyUI`
- Custom node example confirms `WEB_DIRECTORY` is still the runtime contract for frontend extension assets.
- The current core repository hosts compiled frontend assets under `web/`, while frontend sources live separately.
- `Comfy-Org/ComfyUI_frontend`
- The official frontend is TypeScript/Vue-based.
- Extension APIs include `app.registerExtension`, sidebar tabs, commands, keybindings, settings, toast, and desktop-safe dialogs.
- Settings should prefer `app.registerExtension({ settings: [...] })` plus `app.extensionManager.setting.get(...)`, with legacy `app.ui.settings` only as fallback.
- `Comfy-Org/ComfyUI-Manager`
- Custom node packages should not assume their folder name.
- Manager and registry workflows expect reproducible package metadata and install-time scripts from the package root.
- `Comfy-Org/desktop`
- Desktop runs ComfyUI in an Electron environment, so browser-only APIs need ComfyUI dialog/toast fallbacks.
## Current Repository Findings
- The extension intentionally exposes no ComfyUI nodes.
- `__init__.py` keeps `WEB_DIRECTORY = "./js"`, which is correct for ComfyUI runtime loading.
- The frontend was previously maintained directly as browser JavaScript in `js/`.
- UI code already uses ComfyUI sidebar registration when available and a floating fallback for older versions.
- Dialog helpers already prefer `app.extensionManager.dialog` before browser prompt/confirm, which is aligned with desktop guidance.
- Toast helpers already prefer `app.extensionManager.toast`.
- Settings registration used the legacy `app.ui.settings.addSetting` path only.
## Changes Made
- Added TypeScript source tree under `src/`.
- Kept generated runtime modules under `js/` so ComfyUI loading remains unchanged.
- Added `package.json`, `package-lock.json`, and `tsconfig.json`.
- Added local ComfyUI module declarations in `src/types/comfyui.d.ts`.
- Added build scripts:
- `npm run build`
- `npm run typecheck`
- Migrated all previous `js/**/*.js` source files to `src/**/*.ts`.
- Rebuilt `js/**/*.js` from TypeScript.
- Added modern ComfyUI settings integration through `app.registerExtension({ settings: [...] })`.
- Kept a legacy `app.ui.settings.addSetting` fallback for older ComfyUI versions.
- Updated setting reads to prefer `app.extensionManager.setting.get(...)`.
## Vue Decision
Vue was not introduced in this pass.
Reason: ComfyUI frontend itself uses Vue internally, but custom node frontend assets are still loaded through `WEB_DIRECTORY` as extension modules. This repository already has a large DOM-built UI and no existing bundler. Introducing Vue would require bundling, dependency packaging, and lifecycle glue without improving compatibility immediately.
Recommended next step if Vue is desired:
- Split the Project Settings panel into small UI domains first.
- Add a Vite/Vue build only after confirming the generated bundle loads cleanly from `WEB_DIRECTORY`.
- Keep the ComfyUI extension entry as a thin adapter around `app.registerExtension`.
## Remaining Risks
- TypeScript currently builds with `noCheck: true` because the old JavaScript is highly dynamic and depends on untyped ComfyUI internals.
- Generated `.d.ts` files are emitted for future typing work but are not consumed by ComfyUI.
- Full browser verification inside ComfyUI is still required after this migration.
- The UI is still large and imperative; future refactors should target typed API payloads and smaller panel modules before changing framework.
## Recommended Next Refactors
- Add shared TypeScript types for project state, API responses, missing-model scan results, and graph widget shapes.
- Tighten `tsconfig.json` gradually by removing `noCheck` module by module.
- Move repeated DOM helpers out of `ui_components.ts`.
- Consider registering commands/keybindings through ComfyUI's command/keybinding API instead of a raw capture-phase `keydown` listener for `Ctrl+S`.
- Add Manager/registry metadata once package publishing is planned.
+73
View File
@@ -0,0 +1,73 @@
# TypeScript Migration Plan
Goal: migrate the frontend progressively from permissive TypeScript output to typed, ComfyUI-aligned modules without breaking `WEB_DIRECTORY = "./js"` runtime loading.
## Phase 0 - Baseline
- [x] Move frontend source files from `js/**/*.js` to `src/**/*.ts`.
- [x] Add reproducible TypeScript build with `npm run build`.
- [x] Keep generated browser modules in `js/` for ComfyUI.
- [x] Add minimal ComfyUI ambient declarations.
- [x] Register settings through modern `app.registerExtension({ settings })`.
- [x] Keep legacy settings fallback for older ComfyUI builds.
- [x] Document ComfyUI integration audit.
- [x] Verify `npm run build`.
- [x] Verify current validator tests with `unittest`.
## Phase 1 - Shared Types
- [ ] Add typed API response contracts for project list, project set, template preview, workflow save, and model tools.
- [ ] Add typed runtime state interfaces for persisted state and transient UI callbacks.
- [ ] Add typed graph/node/widget interfaces for the subset used by this extension.
- [ ] Replace broad object assumptions in `state_manager.ts`.
- [ ] Replace broad API payload assumptions in `mjr/api.ts`.
## Phase 2 - Low-Risk Strict Modules
- [ ] Remove `noCheck` from `mjr/utils.ts`.
- [ ] Remove `noCheck` from `mjr/log.ts`.
- [ ] Remove `noCheck` from `mjr/api.ts`.
- [ ] Remove `noCheck` from `state_manager.ts`.
- [ ] Enable `noImplicitAny` for the low-risk modules above.
## Phase 3 - ComfyUI Integration Hardening
- [ ] Move Ctrl+S behavior from raw `keydown` capture toward ComfyUI command/keybinding APIs when available.
- [ ] Keep fallback key handling for older ComfyUI builds.
- [ ] Type settings IDs and setting reads.
- [ ] Type sidebar render callbacks.
- [ ] Type dialog and toast adapter return values.
## Phase 4 - Graph And Patch Safety
- [ ] Type graph serialization and project signature objects.
- [ ] Type save-like node detection.
- [ ] Type path widget mutation.
- [ ] Add focused tests or browser fixtures for patching representative save nodes.
- [ ] Keep all path security validation server-side.
## Phase 5 - UI Decomposition
- [ ] Split `ui_components.ts` into smaller modules by panel area.
- [ ] Extract shared DOM helpers.
- [ ] Type model fixer dialog state.
- [ ] Type model downloader dialog state.
- [ ] Type model contribution dialog state.
- [ ] Re-evaluate Vue only after UI modules are isolated and build output is stable.
## Phase 6 - Strict Project
- [ ] Remove `noCheck` from `tsconfig.json`.
- [ ] Enable `strict`.
- [ ] Keep `skipLibCheck` unless upstream ComfyUI types are added.
- [ ] Run `npm run typecheck` in CI or documented release workflow.
- [ ] Document frontend build requirements in `README.md`.
## Verification Gates
- [ ] `npm run build`
- [ ] `npm run typecheck`
- [ ] `python -m unittest discover -s tests -p "test_*.py"`
- [ ] Manual ComfyUI browser load of Project Settings tab.
- [ ] Manual workflow load/save smoke test.
- [ ] Manual save-node patch smoke test.
+6
View File
@@ -0,0 +1,6 @@
/**
* Majoor Project Settings (standalone)
* - Sidebar UI when available (fallback floating panel)
* - No nodes, no polling
*/
export {};
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+19
View File
@@ -0,0 +1,19 @@
export declare function fetchJSON(url: any, opts: any): Promise<any>;
export declare function tryResolveProjectId(folder: any): Promise<any>;
export declare function listProjects(): Promise<any>;
export declare function setProject(project_name: any, create_base?: boolean): Promise<any>;
export declare function getModels(): Promise<any>;
export declare function createCustomOut(payload: any): Promise<any>;
export declare function previewTemplate(template: any, tokens: any): Promise<any>;
export declare function listExistingNames(project_id: any, media?: string): Promise<any>;
export declare function saveWorkflow(payload: any): Promise<any>;
export declare function scanModelCandidates(missing: any): Promise<any>;
export declare function resolveModelRecipes(missing: any): Promise<any>;
export declare function saveModelRecipes(items: any): Promise<any>;
export declare function downloadModels(items: any): Promise<any>;
export declare function getDownloadStatus(job_id: any): Promise<any>;
export declare function getFingerprintCacheStatus(): Promise<any>;
export declare function buildFingerprintCache(payload: any): Promise<any>;
export declare function resolveFingerprint(payload: any): Promise<any>;
export declare function searchRegistry(query: any, limit?: number): Promise<any>;
export declare function contributeRegistry(payload: any): Promise<any>;
+152 -171
View File
@@ -1,216 +1,197 @@
let _csrfTokenPromise = null;
function _getApiKey() {
try {
const key = localStorage.getItem("mjr_api_key");
return (key || "").trim();
} catch (_) {
return "";
}
}
async function _getCsrfToken(forceRefresh = false) {
if (forceRefresh) {
_csrfTokenPromise = null;
}
if (_csrfTokenPromise) return _csrfTokenPromise;
_csrfTokenPromise = (async () => {
try {
const resp = await fetch("/mjr_security/csrf", {
credentials: "same-origin",
headers: {
"Accept": "application/json"
const key = localStorage.getItem("mjr_api_key");
return (key || "").trim();
}
catch (_) {
return "";
}
}
async function _getCsrfToken(forceRefresh = false) {
if (forceRefresh) {
_csrfTokenPromise = null;
}
if (_csrfTokenPromise)
return _csrfTokenPromise;
_csrfTokenPromise = (async () => {
try {
const resp = await fetch("/mjr_security/csrf", {
credentials: "same-origin",
headers: {
"Accept": "application/json"
}
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || data?.ok === false) {
console.error("[MJR] Failed to get CSRF token:", data?.error || `HTTP ${resp.status}`);
throw new Error(data?.error || `HTTP ${resp.status}`);
}
const token = data?.csrf_token || "";
if (!token) {
console.error("[MJR] CSRF token is empty");
}
return token;
}
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || data?.ok === false) {
console.error("[MJR] Failed to get CSRF token:", data?.error || `HTTP ${resp.status}`);
throw new Error(data?.error || `HTTP ${resp.status}`);
}
const token = data?.csrf_token || "";
if (!token) {
console.error("[MJR] CSRF token is empty");
}
return token;
} catch (err) {
console.error("[MJR] Error fetching CSRF token:", err);
_csrfTokenPromise = null; // Reset on error
throw err;
}
})();
return _csrfTokenPromise;
catch (err) {
console.error("[MJR] Error fetching CSRF token:", err);
_csrfTokenPromise = null; // Reset on error
throw err;
}
})();
return _csrfTokenPromise;
}
export async function fetchJSON(url, opts) {
const o = opts ? { ...opts } : {};
o.credentials = o.credentials || "same-origin";
o.headers = { ...(o.headers || {}) };
const apiKey = _getApiKey();
if (apiKey && !o.headers["X-MJR-API-Key"] && !o.headers["Authorization"]) {
o.headers["X-MJR-API-Key"] = apiKey;
}
const method = String(o.method || "GET").toUpperCase();
if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
const csrf = await _getCsrfToken();
if (csrf) {
o.headers["X-CSRF-Token"] = csrf;
} else {
console.warn("[MJR] No CSRF token available for request");
const o = opts ? { ...opts } : {};
o.credentials = o.credentials || "same-origin";
o.headers = { ...(o.headers || {}) };
const apiKey = _getApiKey();
if (apiKey && !o.headers["X-MJR-API-Key"] && !o.headers["Authorization"]) {
o.headers["X-MJR-API-Key"] = apiKey;
}
}
const resp = await fetch(url, o);
// If we get a 403 (Forbidden), it might be a CSRF issue - retry once with fresh token
if (resp.status === 403 && ["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
console.warn("[MJR] Got 403, retrying with fresh CSRF token...");
const freshCsrf = await _getCsrfToken(true);
if (freshCsrf && freshCsrf !== o.headers["X-CSRF-Token"]) {
o.headers["X-CSRF-Token"] = freshCsrf;
const retryResp = await fetch(url, o);
const retryData = await retryResp.json().catch(() => ({}));
if (!retryResp.ok || retryData?.ok === false) {
throw new Error(retryData?.error || `HTTP ${retryResp.status}`);
}
return retryData;
const method = String(o.method || "GET").toUpperCase();
if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
const csrf = await _getCsrfToken();
if (csrf) {
o.headers["X-CSRF-Token"] = csrf;
}
else {
console.warn("[MJR] No CSRF token available for request");
}
}
}
const data = await resp.json().catch(() => ({}));
if (!resp.ok || data?.ok === false) {
throw new Error(data?.error || `HTTP ${resp.status}`);
}
return data;
const resp = await fetch(url, o);
// If we get a 403 (Forbidden), it might be a CSRF issue - retry once with fresh token
if (resp.status === 403 && ["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
console.warn("[MJR] Got 403, retrying with fresh CSRF token...");
const freshCsrf = await _getCsrfToken(true);
if (freshCsrf && freshCsrf !== o.headers["X-CSRF-Token"]) {
o.headers["X-CSRF-Token"] = freshCsrf;
const retryResp = await fetch(url, o);
const retryData = await retryResp.json().catch(() => ({}));
if (!retryResp.ok || retryData?.ok === false) {
throw new Error(retryData?.error || `HTTP ${retryResp.status}`);
}
return retryData;
}
}
const data = await resp.json().catch(() => ({}));
if (!resp.ok || data?.ok === false) {
throw new Error(data?.error || `HTTP ${resp.status}`);
}
return data;
}
export async function tryResolveProjectId(folder) {
try {
const data = await fetchJSON(`/mjr_project/resolve?folder=${encodeURIComponent(folder)}`);
if (data?.ok) return data.project_id || "";
} catch (_) {}
return "";
try {
const data = await fetchJSON(`/mjr_project/resolve?folder=${encodeURIComponent(folder)}`);
if (data?.ok)
return data.project_id || "";
}
catch (_) { }
return "";
}
export async function listProjects() {
return fetchJSON("/mjr_project/list");
return fetchJSON("/mjr_project/list");
}
export async function setProject(project_name, create_base = true) {
return fetchJSON("/mjr_project/set", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ project_name, create_base }),
});
return fetchJSON("/mjr_project/set", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ project_name, create_base }),
});
}
export async function getModels() {
return fetchJSON("/mjr_project/models");
return fetchJSON("/mjr_project/models");
}
export async function createCustomOut(payload) {
return fetchJSON("/mjr_project/create_custom_out", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload || {}),
});
return fetchJSON("/mjr_project/create_custom_out", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload || {}),
});
}
export async function previewTemplate(template, tokens) {
return fetchJSON("/mjr_project/preview_template", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ template, tokens: tokens || {} }),
});
return fetchJSON("/mjr_project/preview_template", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ template, tokens: tokens || {} }),
});
}
export async function listExistingNames(project_id, media = "images") {
const qs = new URLSearchParams({
project_id: project_id || "",
media: media || "images",
});
const data = await fetchJSON(`/mjr_project/assets/list_names?${qs.toString()}`);
return Array.isArray(data?.names) ? data.names : [];
const qs = new URLSearchParams({
project_id: project_id || "",
media: media || "images",
});
const data = await fetchJSON(`/mjr_project/assets/list_names?${qs.toString()}`);
return Array.isArray(data?.names) ? data.names : [];
}
export async function saveWorkflow(payload) {
return fetchJSON("/mjr_project/workflow/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload || {}),
});
return fetchJSON("/mjr_project/workflow/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload || {}),
});
}
export async function scanModelCandidates(missing) {
return fetchJSON("/mjr_models/scan_candidates", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ missing: missing || [] }),
});
return fetchJSON("/mjr_models/scan_candidates", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ missing: missing || [] }),
});
}
export async function resolveModelRecipes(missing) {
return fetchJSON("/mjr_models/resolve_recipes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ missing: missing || [] }),
});
return fetchJSON("/mjr_models/resolve_recipes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ missing: missing || [] }),
});
}
export async function saveModelRecipes(items) {
return fetchJSON("/mjr_models/save_recipes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: items || [] }),
});
return fetchJSON("/mjr_models/save_recipes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: items || [] }),
});
}
export async function downloadModels(items) {
return fetchJSON("/mjr_models/download", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: items || [] }),
});
return fetchJSON("/mjr_models/download", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: items || [] }),
});
}
export async function getDownloadStatus(job_id) {
const qs = new URLSearchParams({ job_id: job_id || "" });
return fetchJSON(`/mjr_models/download_status?${qs.toString()}`);
const qs = new URLSearchParams({ job_id: job_id || "" });
return fetchJSON(`/mjr_models/download_status?${qs.toString()}`);
}
export async function getFingerprintCacheStatus() {
return fetchJSON("/mjr_models/fingerprint_cache_status");
return fetchJSON("/mjr_models/fingerprint_cache_status");
}
export async function buildFingerprintCache(payload) {
return fetchJSON("/mjr_models/build_fingerprint_cache", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload || {}),
});
return fetchJSON("/mjr_models/build_fingerprint_cache", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload || {}),
});
}
export async function resolveFingerprint(payload) {
return fetchJSON("/mjr_models/resolve_by_fingerprint", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload || {}),
});
return fetchJSON("/mjr_models/resolve_by_fingerprint", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload || {}),
});
}
export async function searchRegistry(query, limit = 6) {
return fetchJSON("/mjr_models/registry/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: String(query || ""), limit }),
});
return fetchJSON("/mjr_models/registry/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: String(query || ""), limit }),
});
}
export async function contributeRegistry(payload) {
return fetchJSON("/mjr_models/registry/contribute", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload || {}),
});
return fetchJSON("/mjr_models/registry/contribute", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload || {}),
});
}
//# sourceMappingURL=api.js.map
File diff suppressed because one or more lines are too long
+2
View File
@@ -0,0 +1,2 @@
export declare function psPrompt({ title, message, defaultValue }?: {}): Promise<string>;
export declare function psConfirm({ title, message }?: {}): Promise<boolean>;
+41 -37
View File
@@ -1,42 +1,46 @@
import { app } from "../../../scripts/app.js";
export async function psPrompt({ title, message, defaultValue } = {}) {
const dialog = app?.extensionManager?.dialog;
if (dialog?.prompt) {
try {
const result = await dialog.prompt({
title: title || "Input",
message: message || "",
defaultValue: defaultValue ?? "",
default: defaultValue ?? "",
});
if (result === null || result === undefined) return null;
if (typeof result === "object" && "value" in result) {
const value = result.value;
return value == null ? null : String(value);
}
return String(result);
} catch (_) {}
}
const fallback = window.prompt(message || title || "", defaultValue ?? "");
if (fallback === null) return null;
return String(fallback);
const dialog = app?.extensionManager?.dialog;
if (dialog?.prompt) {
try {
const result = await dialog.prompt({
title: title || "Input",
message: message || "",
defaultValue: defaultValue ?? "",
default: defaultValue ?? "",
});
if (result === null || result === undefined)
return null;
if (typeof result === "object" && "value" in result) {
const value = result.value;
return value == null ? null : String(value);
}
return String(result);
}
catch (_) { }
}
const fallback = window.prompt(message || title || "", defaultValue ?? "");
if (fallback === null)
return null;
return String(fallback);
}
export async function psConfirm({ title, message } = {}) {
const dialog = app?.extensionManager?.dialog;
if (dialog?.confirm) {
try {
const result = await dialog.confirm({
title: title || "Confirm",
message: message || "",
});
if (typeof result === "boolean") return result;
if (typeof result === "object" && "value" in result) {
return Boolean(result.value);
}
return Boolean(result);
} catch (_) {}
}
return window.confirm(message || title || "Confirm?");
const dialog = app?.extensionManager?.dialog;
if (dialog?.confirm) {
try {
const result = await dialog.confirm({
title: title || "Confirm",
message: message || "",
});
if (typeof result === "boolean")
return result;
if (typeof result === "object" && "value" in result) {
return Boolean(result.value);
}
return Boolean(result);
}
catch (_) { }
}
return window.confirm(message || title || "Confirm?");
}
//# sourceMappingURL=dialog.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"dialog.js","sourceRoot":"","sources":["../../src/mjr/dialog.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,yBAAyB,CAAC;AAE9C,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,YAAY,EAAE,GAAG,EAAE;IAClE,MAAM,MAAM,GAAG,GAAG,EAAE,gBAAgB,EAAE,MAAM,CAAC;IAC7C,IAAI,MAAM,EAAE,MAAM,EAAE,CAAC;QACnB,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC;gBACjC,KAAK,EAAE,KAAK,IAAI,OAAO;gBACvB,OAAO,EAAE,OAAO,IAAI,EAAE;gBACtB,YAAY,EAAE,YAAY,IAAI,EAAE;gBAChC,OAAO,EAAE,YAAY,IAAI,EAAE;aAC5B,CAAC,CAAC;YACH,IAAI,MAAM,KAAK,IAAI,IAAI,MAAM,KAAK,SAAS;gBAAE,OAAO,IAAI,CAAC;YACzD,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,OAAO,IAAI,MAAM,EAAE,CAAC;gBACpD,MAAM,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC;gBAC3B,OAAO,KAAK,IAAI,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;YAC9C,CAAC;YACD,OAAO,MAAM,CAAC,MAAM,CAAC,CAAC;QACxB,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC,CAAA,CAAC;IAChB,CAAC;IACD,MAAM,QAAQ,GAAG,MAAM,CAAC,MAAM,CAAC,OAAO,IAAI,KAAK,IAAI,EAAE,EAAE,YAAY,IAAI,EAAE,CAAC,CAAC;IAC3E,IAAI,QAAQ,KAAK,IAAI;QAAE,OAAO,IAAI,CAAC;IACnC,OAAO,MAAM,CAAC,QAAQ,CAAC,CAAC;AAC1B,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,SAAS,CAAC,EAAE,KAAK,EAAE,OAAO,EAAE,GAAG,EAAE;IACrD,MAAM,MAAM,GAAG,GAAG,EAAE,gBAAgB,EAAE,MAAM,CAAC;IAC7C,IAAI,MAAM,EAAE,OAAO,EAAE,CAAC;QACpB,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,OAAO,CAAC;gBAClC,KAAK,EAAE,KAAK,IAAI,SAAS;gBACzB,OAAO,EAAE,OAAO,IAAI,EAAE;aACvB,CAAC,CAAC;YACH,IAAI,OAAO,MAAM,KAAK,SAAS;gBAAE,OAAO,MAAM,CAAC;YAC/C,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,OAAO,IAAI,MAAM,EAAE,CAAC;gBACpD,OAAO,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;YAC/B,CAAC;YACD,OAAO,OAAO,CAAC,MAAM,CAAC,CAAC;QACzB,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC,CAAA,CAAC;IAChB,CAAC;IACD,OAAO,MAAM,CAAC,OAAO,CAAC,OAAO,IAAI,KAAK,IAAI,UAAU,CAAC,CAAC;AACxD,CAAC"}
+4
View File
@@ -0,0 +1,4 @@
export declare function readGraphSignature(): any;
export declare function inferProjectFolderFromGraph(isSaveLikeNodeFn: any): any;
export declare function detectModelFromGraph(token3TagFn: any): any;
export declare function getSerializedWorkflow(): any;
+137 -123
View File
@@ -1,129 +1,143 @@
import { app } from "../../../scripts/app.js";
const DEBUG = false;
function getInnerGraphFromNode(node) {
if (!node) return null;
const candidates = [
node.subgraph,
node._subgraph,
node?.subgraph?.graph,
node?.subgraph?.lgraph,
node?.properties?.subgraph,
node.subgraph_instance,
node?.subgraph_instance?.graph,
];
for (const candidate of candidates) {
if (!candidate) continue;
if (Array.isArray(candidate?._nodes)) return candidate;
if (Array.isArray(candidate?.graph?._nodes)) return candidate.graph;
}
return null;
}
function scanGraphForModel(graph, token3TagFn, visited) {
if (!graph || !Array.isArray(graph._nodes)) return "";
if (visited.has(graph)) return "";
visited.add(graph);
const keys = [
"ckpt",
"checkpoint",
"checkpoint_name",
"ckpt_name",
"model",
"model_name",
"diffusion",
"diffusion_model",
"unet",
"unet_name",
"gguf",
];
for (const node of graph._nodes) {
const widgets = node?.widgets || [];
for (const w of widgets) {
const name = String(w?.name || "").toLowerCase();
if (!name) continue;
if (!keys.some((k) => name.includes(k))) continue;
const val = w?.value;
if (typeof val !== "string") continue;
const tag = token3TagFn ? token3TagFn(val, false) : "";
if (tag) {
if (DEBUG) console.debug("[mjr] model tag found", tag);
return tag;
}
if (!node)
return null;
const candidates = [
node.subgraph,
node._subgraph,
node?.subgraph?.graph,
node?.subgraph?.lgraph,
node?.properties?.subgraph,
node.subgraph_instance,
node?.subgraph_instance?.graph,
];
for (const candidate of candidates) {
if (!candidate)
continue;
if (Array.isArray(candidate?._nodes))
return candidate;
if (Array.isArray(candidate?.graph?._nodes))
return candidate.graph;
}
const inner = getInnerGraphFromNode(node);
if (inner) {
if (DEBUG) console.debug("[mjr] scanning subgraph for model");
const subTag = scanGraphForModel(inner, token3TagFn, visited);
if (subTag) return subTag;
}
}
return "";
}
function scanGraphForProjectFolder(graph, isSaveLikeNodeFn, visited) {
if (!graph || !Array.isArray(graph._nodes)) return "";
if (visited.has(graph)) return "";
visited.add(graph);
for (const node of graph._nodes) {
if (isSaveLikeNodeFn && !isSaveLikeNodeFn(node)) continue;
const widgets = node?.widgets || [];
for (const w of widgets) {
const v = w?.value;
if (typeof v === "string" || typeof v === "number") {
const s = String(v).replace(/\\/g, "/");
const m = s.match(/PROJECTS\/([^/]+)/i);
if (m && m[1]) return m[1];
}
}
const inner = getInnerGraphFromNode(node);
if (inner) {
if (DEBUG) console.debug("[mjr] scanning subgraph for project folder");
const subFolder = scanGraphForProjectFolder(inner, isSaveLikeNodeFn, visited);
if (subFolder) return subFolder;
}
}
return "";
}
export function readGraphSignature() {
const g = app?.graph;
if (!g) return null;
const direct = g?.extra?.mjr_project;
if (direct && typeof direct === "object") return direct;
try {
const ser = g?.serialize?.();
const sig = ser?.extra?.mjr_project;
if (sig && typeof sig === "object") return sig;
} catch (_) {}
return null;
}
export function inferProjectFolderFromGraph(isSaveLikeNodeFn) {
const g = app?.graph;
return scanGraphForProjectFolder(g, isSaveLikeNodeFn, new WeakSet());
}
export function detectModelFromGraph(token3TagFn) {
const g = app?.graph;
return scanGraphForModel(g, token3TagFn, new WeakSet());
}
export function getSerializedWorkflow() {
const g = app?.graph;
if (!g || typeof g.serialize !== "function") return null;
try {
const ser = g.serialize();
if (g.extra && typeof g.extra === "object") {
ser.extra = ser.extra || {};
Object.assign(ser.extra, g.extra);
}
return ser;
} catch (_) {
return null;
}
}
function scanGraphForModel(graph, token3TagFn, visited) {
if (!graph || !Array.isArray(graph._nodes))
return "";
if (visited.has(graph))
return "";
visited.add(graph);
const keys = [
"ckpt",
"checkpoint",
"checkpoint_name",
"ckpt_name",
"model",
"model_name",
"diffusion",
"diffusion_model",
"unet",
"unet_name",
"gguf",
];
for (const node of graph._nodes) {
const widgets = node?.widgets || [];
for (const w of widgets) {
const name = String(w?.name || "").toLowerCase();
if (!name)
continue;
if (!keys.some((k) => name.includes(k)))
continue;
const val = w?.value;
if (typeof val !== "string")
continue;
const tag = token3TagFn ? token3TagFn(val, false) : "";
if (tag) {
if (DEBUG)
console.debug("[mjr] model tag found", tag);
return tag;
}
}
const inner = getInnerGraphFromNode(node);
if (inner) {
if (DEBUG)
console.debug("[mjr] scanning subgraph for model");
const subTag = scanGraphForModel(inner, token3TagFn, visited);
if (subTag)
return subTag;
}
}
return "";
}
function scanGraphForProjectFolder(graph, isSaveLikeNodeFn, visited) {
if (!graph || !Array.isArray(graph._nodes))
return "";
if (visited.has(graph))
return "";
visited.add(graph);
for (const node of graph._nodes) {
if (isSaveLikeNodeFn && !isSaveLikeNodeFn(node))
continue;
const widgets = node?.widgets || [];
for (const w of widgets) {
const v = w?.value;
if (typeof v === "string" || typeof v === "number") {
const s = String(v).replace(/\\/g, "/");
const m = s.match(/PROJECTS\/([^/]+)/i);
if (m && m[1])
return m[1];
}
}
const inner = getInnerGraphFromNode(node);
if (inner) {
if (DEBUG)
console.debug("[mjr] scanning subgraph for project folder");
const subFolder = scanGraphForProjectFolder(inner, isSaveLikeNodeFn, visited);
if (subFolder)
return subFolder;
}
}
return "";
}
export function readGraphSignature() {
const g = app?.graph;
if (!g)
return null;
const direct = g?.extra?.mjr_project;
if (direct && typeof direct === "object")
return direct;
try {
const ser = g?.serialize?.();
const sig = ser?.extra?.mjr_project;
if (sig && typeof sig === "object")
return sig;
}
catch (_) { }
return null;
}
export function inferProjectFolderFromGraph(isSaveLikeNodeFn) {
const g = app?.graph;
return scanGraphForProjectFolder(g, isSaveLikeNodeFn, new WeakSet());
}
export function detectModelFromGraph(token3TagFn) {
const g = app?.graph;
return scanGraphForModel(g, token3TagFn, new WeakSet());
}
export function getSerializedWorkflow() {
const g = app?.graph;
if (!g || typeof g.serialize !== "function")
return null;
try {
const ser = g.serialize();
if (g.extra && typeof g.extra === "object") {
ser.extra = ser.extra || {};
Object.assign(ser.extra, g.extra);
}
return ser;
}
catch (_) {
return null;
}
}
//# sourceMappingURL=graph.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"graph.js","sourceRoot":"","sources":["../../src/mjr/graph.ts"],"names":[],"mappings":"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"}
+5
View File
@@ -0,0 +1,5 @@
export declare const MJR_DEBUG: boolean;
export declare function debug(...args: any[]): void;
export declare function info(...args: any[]): void;
export declare function warn(...args: any[]): void;
export declare function error(...args: any[]): void;
+7 -10
View File
@@ -1,20 +1,17 @@
// Simple logging wrapper for Majoor Project Settings UI
export const MJR_DEBUG = (typeof window !== 'undefined' && Boolean(window?.MJR_DEBUG)) || false;
export function debug(...args) {
if (MJR_DEBUG) {
console.debug(...args);
}
if (MJR_DEBUG) {
console.debug(...args);
}
}
export function info(...args) {
console.info(...args);
console.info(...args);
}
export function warn(...args) {
console.warn(...args);
console.warn(...args);
}
export function error(...args) {
console.error(...args);
console.error(...args);
}
//# sourceMappingURL=log.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"log.js","sourceRoot":"","sources":["../../src/mjr/log.ts"],"names":[],"mappings":"AAAA,wDAAwD;AACxD,MAAM,CAAC,MAAM,SAAS,GAAG,CAAC,OAAO,MAAM,KAAK,WAAW,IAAI,OAAO,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC,IAAI,KAAK,CAAC;AAEhG,MAAM,UAAU,KAAK,CAAC,GAAG,IAAI;IAC3B,IAAI,SAAS,EAAE,CAAC;QACd,OAAO,CAAC,KAAK,CAAC,GAAG,IAAI,CAAC,CAAC;IACzB,CAAC;AACH,CAAC;AAED,MAAM,UAAU,IAAI,CAAC,GAAG,IAAI;IAC1B,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC,CAAC;AACxB,CAAC;AAED,MAAM,UAAU,IAAI,CAAC,GAAG,IAAI;IAC1B,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC,CAAC;AACxB,CAAC;AAED,MAAM,UAAU,KAAK,CAAC,GAAG,IAAI;IAC3B,OAAO,CAAC,KAAK,CAAC,GAAG,IAAI,CAAC,CAAC;AACzB,CAAC"}
+1
View File
@@ -0,0 +1 @@
export declare function showModelContributionDialog(modelName?: string): void;
+278 -316
View File
@@ -1,335 +1,297 @@
import { ensureStyles, toast } from "./toast.js";
import { contributeRegistry } from "./api.js";
const PLATFORM_OPTIONS = [
{ value: "huggingface", label: "HuggingFace" },
{ value: "civitai", label: "CivitAI" },
{ value: "github", label: "GitHub" },
{ value: "modelscope", label: "ModelScope" },
{ value: "other", label: "Other" },
{ value: "huggingface", label: "HuggingFace" },
{ value: "civitai", label: "CivitAI" },
{ value: "github", label: "GitHub" },
{ value: "modelscope", label: "ModelScope" },
{ value: "other", label: "Other" },
];
const TYPE_OPTIONS = [
{ value: "checkpoints", label: "Checkpoint" },
{ value: "loras", label: "LoRA" },
{ value: "vae", label: "VAE" },
{ value: "controlnet", label: "ControlNet" },
{ value: "embeddings", label: "Embedding" },
{ value: "checkpoints", label: "Checkpoint" },
{ value: "loras", label: "LoRA" },
{ value: "vae", label: "VAE" },
{ value: "controlnet", label: "ControlNet" },
{ value: "embeddings", label: "Embedding" },
];
class ModelContributionDialog {
constructor() {
this.dialog = null;
this.statusEl = null;
this.submitBtn = null;
this.validateBtn = null;
this.inputs = {};
}
show(modelName = "") {
this.close();
ensureStyles();
this._render(modelName);
}
close() {
if (this.dialog) {
this.dialog.remove();
this.dialog = null;
this.statusEl = null;
this.submitBtn = null;
this.validateBtn = null;
this.inputs = {};
constructor() {
this.dialog = null;
this.statusEl = null;
this.submitBtn = null;
this.validateBtn = null;
this.inputs = {};
}
}
_render(modelName) {
this.dialog = document.createElement("div");
this.dialog.className = "mjr-contribution-dialog";
Object.assign(this.dialog.style, {
position: "fixed",
left: "0",
top: "0",
width: "100%",
height: "100%",
zIndex: "9999",
pointerEvents: "none",
});
const overlay = document.createElement("div");
Object.assign(overlay.style, {
position: "absolute",
inset: "0",
background: "rgba(0,0,0,0.65)",
backdropFilter: "blur(4px)",
});
const panel = document.createElement("div");
Object.assign(panel.style, {
position: "absolute",
left: "50%",
top: "50%",
transform: "translate(-50%, -50%)",
background: "rgba(15, 18, 26, 0.95)",
border: "1px solid rgba(255,255,255,0.2)",
borderRadius: "16px",
padding: "24px",
width: "360px",
color: "#f2f4f8",
fontFamily: "inherit",
pointerEvents: "auto",
boxShadow: "0 20px 50px rgba(0,0,0,0.4)",
display: "flex",
flexDirection: "column",
gap: "12px",
});
panel.innerHTML = `
show(modelName = "") {
this.close();
ensureStyles();
this._render(modelName);
}
close() {
if (this.dialog) {
this.dialog.remove();
this.dialog = null;
this.statusEl = null;
this.submitBtn = null;
this.validateBtn = null;
this.inputs = {};
}
}
_render(modelName) {
this.dialog = document.createElement("div");
this.dialog.className = "mjr-contribution-dialog";
Object.assign(this.dialog.style, {
position: "fixed",
left: "0",
top: "0",
width: "100%",
height: "100%",
zIndex: "9999",
pointerEvents: "none",
});
const overlay = document.createElement("div");
Object.assign(overlay.style, {
position: "absolute",
inset: "0",
background: "rgba(0,0,0,0.65)",
backdropFilter: "blur(4px)",
});
const panel = document.createElement("div");
Object.assign(panel.style, {
position: "absolute",
left: "50%",
top: "50%",
transform: "translate(-50%, -50%)",
background: "rgba(15, 18, 26, 0.95)",
border: "1px solid rgba(255,255,255,0.2)",
borderRadius: "16px",
padding: "24px",
width: "360px",
color: "#f2f4f8",
fontFamily: "inherit",
pointerEvents: "auto",
boxShadow: "0 20px 50px rgba(0,0,0,0.4)",
display: "flex",
flexDirection: "column",
gap: "12px",
});
panel.innerHTML = `
<h2 style="margin:0;font-size:18px;letter-spacing:0.5px;text-transform:uppercase;">Share a model URL</h2>
<p style="margin:0;font-size:12px;color:#a0b3d6;">Help others by contributing a verified download link.</p>
`;
const fields = [
{ id: "name", label: "Model name", placeholder: "Stable Diffusion 1.5" },
{ id: "url", label: "Download URL", placeholder: "https://huggingface.co/.../model.safetensors" },
];
for (const field of fields) {
const wrap = document.createElement("div");
wrap.style.display = "flex";
wrap.style.flexDirection = "column";
wrap.style.gap = "4px";
const label = document.createElement("label");
label.textContent = field.label;
label.style.fontSize = "11px";
label.style.opacity = "0.8";
const input = document.createElement("input");
input.type = "text";
input.placeholder = field.placeholder;
input.style.padding = "8px 10px";
input.style.borderRadius = "8px";
input.style.border = "1px solid rgba(255,255,255,0.15)";
input.style.background = "rgba(255,255,255,0.04)";
input.style.color = "#f4f6fb";
input.style.fontSize = "13px";
if (field.id === "name" && modelName) {
input.value = modelName;
}
wrap.appendChild(label);
wrap.appendChild(input);
panel.appendChild(wrap);
this.inputs[field.id] = input;
const fields = [
{ id: "name", label: "Model name", placeholder: "Stable Diffusion 1.5" },
{ id: "url", label: "Download URL", placeholder: "https://huggingface.co/.../model.safetensors" },
];
for (const field of fields) {
const wrap = document.createElement("div");
wrap.style.display = "flex";
wrap.style.flexDirection = "column";
wrap.style.gap = "4px";
const label = document.createElement("label");
label.textContent = field.label;
label.style.fontSize = "11px";
label.style.opacity = "0.8";
const input = document.createElement("input");
input.type = "text";
input.placeholder = field.placeholder;
input.style.padding = "8px 10px";
input.style.borderRadius = "8px";
input.style.border = "1px solid rgba(255,255,255,0.15)";
input.style.background = "rgba(255,255,255,0.04)";
input.style.color = "#f4f6fb";
input.style.fontSize = "13px";
if (field.id === "name" && modelName) {
input.value = modelName;
}
wrap.appendChild(label);
wrap.appendChild(input);
panel.appendChild(wrap);
this.inputs[field.id] = input;
}
const selects = [
{ id: "platform", label: "Platform", options: PLATFORM_OPTIONS },
{ id: "type", label: "Model type", options: TYPE_OPTIONS },
];
for (const selectCfg of selects) {
const wrap = document.createElement("div");
wrap.style.display = "flex";
wrap.style.flexDirection = "column";
wrap.style.gap = "4px";
const label = document.createElement("label");
label.textContent = selectCfg.label;
label.style.fontSize = "11px";
label.style.opacity = "0.8";
const select = document.createElement("select");
select.style.padding = "8px 10px";
select.style.borderRadius = "8px";
select.style.border = "1px solid rgba(255,255,255,0.15)";
select.style.background = "rgba(255,255,255,0.04)";
select.style.color = "#f4f6fb";
select.style.fontSize = "13px";
for (const option of selectCfg.options) {
const opt = document.createElement("option");
opt.value = option.value;
opt.textContent = option.label;
select.appendChild(opt);
}
wrap.appendChild(label);
wrap.appendChild(select);
panel.appendChild(wrap);
this.inputs[selectCfg.id] = select;
}
const extraWrap = document.createElement("div");
extraWrap.style.display = "flex";
extraWrap.style.flexDirection = "column";
extraWrap.style.gap = "4px";
const fileLabel = document.createElement("label");
fileLabel.textContent = "Filename (optional)";
fileLabel.style.fontSize = "11px";
fileLabel.style.opacity = "0.8";
const fileInput = document.createElement("input");
fileInput.type = "text";
fileInput.placeholder = "model.safetensors";
Object.assign(fileInput.style, {
padding: "8px 10px",
borderRadius: "8px",
border: "1px solid rgba(255,255,255,0.15)",
background: "rgba(255,255,255,0.04)",
color: "#f4f6fb",
fontSize: "13px",
});
const shaLabel = document.createElement("label");
shaLabel.textContent = "SHA256 (optional)";
shaLabel.style.fontSize = "11px";
shaLabel.style.opacity = "0.8";
const shaInput = document.createElement("input");
shaInput.type = "text";
shaInput.placeholder = "abc123...";
Object.assign(shaInput.style, {
padding: "8px 10px",
borderRadius: "8px",
border: "1px solid rgba(255,255,255,0.15)",
background: "rgba(255,255,255,0.04)",
color: "#f4f6fb",
fontSize: "13px",
});
extraWrap.appendChild(fileLabel);
extraWrap.appendChild(fileInput);
extraWrap.appendChild(shaLabel);
extraWrap.appendChild(shaInput);
panel.appendChild(extraWrap);
this.inputs.filename = fileInput;
this.inputs.sha256 = shaInput;
this.statusEl = document.createElement("div");
this.statusEl.style.minHeight = "20px";
this.statusEl.style.fontSize = "12px";
this.statusEl.style.opacity = "0.85";
panel.appendChild(this.statusEl);
const actions = document.createElement("div");
actions.style.display = "flex";
actions.style.gap = "8px";
actions.style.justifyContent = "space-between";
this.validateBtn = document.createElement("button");
this.validateBtn.textContent = "Validate URL";
this.validateBtn.className = "mjr-ps-btn";
this.validateBtn.style.flex = "1";
this.validateBtn.type = "button";
this.submitBtn = document.createElement("button");
this.submitBtn.textContent = "Contribute";
this.submitBtn.className = "mjr-ps-btn";
this.submitBtn.style.flex = "1";
this.submitBtn.type = "button";
actions.appendChild(this.validateBtn);
actions.appendChild(this.submitBtn);
panel.appendChild(actions);
const closeBtn = document.createElement("button");
closeBtn.textContent = "Cancel";
closeBtn.type = "button";
closeBtn.style.alignSelf = "flex-end";
closeBtn.style.fontSize = "12px";
closeBtn.style.background = "transparent";
closeBtn.style.border = "none";
closeBtn.style.color = "#9ab1d8";
panel.appendChild(closeBtn);
this.dialog.appendChild(overlay);
this.dialog.appendChild(panel);
document.body.appendChild(this.dialog);
this.validateBtn.addEventListener("click", () => this._validateUrl());
this.submitBtn.addEventListener("click", () => this._submit());
closeBtn.addEventListener("click", () => this.close());
overlay.addEventListener("click", () => this.close());
}
const selects = [
{ id: "platform", label: "Platform", options: PLATFORM_OPTIONS },
{ id: "type", label: "Model type", options: TYPE_OPTIONS },
];
for (const selectCfg of selects) {
const wrap = document.createElement("div");
wrap.style.display = "flex";
wrap.style.flexDirection = "column";
wrap.style.gap = "4px";
const label = document.createElement("label");
label.textContent = selectCfg.label;
label.style.fontSize = "11px";
label.style.opacity = "0.8";
const select = document.createElement("select");
select.style.padding = "8px 10px";
select.style.borderRadius = "8px";
select.style.border = "1px solid rgba(255,255,255,0.15)";
select.style.background = "rgba(255,255,255,0.04)";
select.style.color = "#f4f6fb";
select.style.fontSize = "13px";
for (const option of selectCfg.options) {
const opt = document.createElement("option");
opt.value = option.value;
opt.textContent = option.label;
select.appendChild(opt);
}
wrap.appendChild(label);
wrap.appendChild(select);
panel.appendChild(wrap);
this.inputs[selectCfg.id] = select;
_setStatus(message, tone = "info") {
if (!this.statusEl)
return;
this.statusEl.textContent = message;
const colors = {
info: "#a0b3d6",
success: "#84ff9c",
error: "#f87c7c",
};
this.statusEl.style.color = colors[tone] || colors.info;
}
const extraWrap = document.createElement("div");
extraWrap.style.display = "flex";
extraWrap.style.flexDirection = "column";
extraWrap.style.gap = "4px";
const fileLabel = document.createElement("label");
fileLabel.textContent = "Filename (optional)";
fileLabel.style.fontSize = "11px";
fileLabel.style.opacity = "0.8";
const fileInput = document.createElement("input");
fileInput.type = "text";
fileInput.placeholder = "model.safetensors";
Object.assign(fileInput.style, {
padding: "8px 10px",
borderRadius: "8px",
border: "1px solid rgba(255,255,255,0.15)",
background: "rgba(255,255,255,0.04)",
color: "#f4f6fb",
fontSize: "13px",
});
const shaLabel = document.createElement("label");
shaLabel.textContent = "SHA256 (optional)";
shaLabel.style.fontSize = "11px";
shaLabel.style.opacity = "0.8";
const shaInput = document.createElement("input");
shaInput.type = "text";
shaInput.placeholder = "abc123...";
Object.assign(shaInput.style, {
padding: "8px 10px",
borderRadius: "8px",
border: "1px solid rgba(255,255,255,0.15)",
background: "rgba(255,255,255,0.04)",
color: "#f4f6fb",
fontSize: "13px",
});
extraWrap.appendChild(fileLabel);
extraWrap.appendChild(fileInput);
extraWrap.appendChild(shaLabel);
extraWrap.appendChild(shaInput);
panel.appendChild(extraWrap);
this.inputs.filename = fileInput;
this.inputs.sha256 = shaInput;
this.statusEl = document.createElement("div");
this.statusEl.style.minHeight = "20px";
this.statusEl.style.fontSize = "12px";
this.statusEl.style.opacity = "0.85";
panel.appendChild(this.statusEl);
const actions = document.createElement("div");
actions.style.display = "flex";
actions.style.gap = "8px";
actions.style.justifyContent = "space-between";
this.validateBtn = document.createElement("button");
this.validateBtn.textContent = "Validate URL";
this.validateBtn.className = "mjr-ps-btn";
this.validateBtn.style.flex = "1";
this.validateBtn.type = "button";
this.submitBtn = document.createElement("button");
this.submitBtn.textContent = "Contribute";
this.submitBtn.className = "mjr-ps-btn";
this.submitBtn.style.flex = "1";
this.submitBtn.type = "button";
actions.appendChild(this.validateBtn);
actions.appendChild(this.submitBtn);
panel.appendChild(actions);
const closeBtn = document.createElement("button");
closeBtn.textContent = "Cancel";
closeBtn.type = "button";
closeBtn.style.alignSelf = "flex-end";
closeBtn.style.fontSize = "12px";
closeBtn.style.background = "transparent";
closeBtn.style.border = "none";
closeBtn.style.color = "#9ab1d8";
panel.appendChild(closeBtn);
this.dialog.appendChild(overlay);
this.dialog.appendChild(panel);
document.body.appendChild(this.dialog);
this.validateBtn.addEventListener("click", () => this._validateUrl());
this.submitBtn.addEventListener("click", () => this._submit());
closeBtn.addEventListener("click", () => this.close());
overlay.addEventListener("click", () => this.close());
}
_setStatus(message, tone = "info") {
if (!this.statusEl) return;
this.statusEl.textContent = message;
const colors = {
info: "#a0b3d6",
success: "#84ff9c",
error: "#f87c7c",
};
this.statusEl.style.color = colors[tone] || colors.info;
}
_validateUrl() {
const value = (this.inputs.url?.value || "").trim();
if (!value) {
this._setStatus("Enter the download URL first.", "error");
return;
_validateUrl() {
const value = (this.inputs.url?.value || "").trim();
if (!value) {
this._setStatus("Enter the download URL first.", "error");
return;
}
if (!/^https?:\/\//i.test(value)) {
this._setStatus("URL must start with http:// or https://", "error");
return;
}
this._setStatus("URL format looks good. You can submit!", "success");
}
if (!/^https?:\/\//i.test(value)) {
this._setStatus("URL must start with http:// or https://", "error");
return;
async _submit() {
if (!this.submitBtn || !this.dialog)
return;
const name = (this.inputs.name?.value || "").trim();
const url = (this.inputs.url?.value || "").trim();
if (!name || !url) {
this._setStatus("Model name and URL are required.", "error");
return;
}
if (!/^https?:\/\//i.test(url)) {
this._setStatus("URL must start with http(s)", "error");
return;
}
this.submitBtn.disabled = true;
this.validateBtn.disabled = true;
this._setStatus("Sending contribution...", "info");
try {
const payload = {
name,
url,
platform: this.inputs.platform?.value || "unknown",
type: this.inputs.type?.value || "checkpoints",
filename: (this.inputs.filename?.value || "").trim(),
sha256: (this.inputs.sha256?.value || "").trim(),
};
const resp = await contributeRegistry(payload);
const added = resp?.added || false;
this._setStatus(added ? "Contribution recorded, thanks!" : "Source already exists.", "success");
toast("success", "Contribution", added ? "Thank you for sharing!" : "Source already in registry.");
if (added) {
setTimeout(() => this.close(), 1600);
}
}
catch (error) {
console.error("Contribution failed", error);
this._setStatus("Failed to send contribution.", "error");
toast("error", "Contribution failed", String(error?.message || error));
}
finally {
this.submitBtn.disabled = false;
this.validateBtn.disabled = false;
}
}
this._setStatus("URL format looks good. You can submit!", "success");
}
async _submit() {
if (!this.submitBtn || !this.dialog) return;
const name = (this.inputs.name?.value || "").trim();
const url = (this.inputs.url?.value || "").trim();
if (!name || !url) {
this._setStatus("Model name and URL are required.", "error");
return;
}
if (!/^https?:\/\//i.test(url)) {
this._setStatus("URL must start with http(s)", "error");
return;
}
this.submitBtn.disabled = true;
this.validateBtn.disabled = true;
this._setStatus("Sending contribution...", "info");
try {
const payload = {
name,
url,
platform: this.inputs.platform?.value || "unknown",
type: this.inputs.type?.value || "checkpoints",
filename: (this.inputs.filename?.value || "").trim(),
sha256: (this.inputs.sha256?.value || "").trim(),
};
const resp = await contributeRegistry(payload);
const added = resp?.added || false;
this._setStatus(added ? "Contribution recorded, thanks!" : "Source already exists.", "success");
toast("success", "Contribution", added ? "Thank you for sharing!" : "Source already in registry.");
if (added) {
setTimeout(() => this.close(), 1600);
}
} catch (error) {
console.error("Contribution failed", error);
this._setStatus("Failed to send contribution.", "error");
toast("error", "Contribution failed", String(error?.message || error));
} finally {
this.submitBtn.disabled = false;
this.validateBtn.disabled = false;
}
}
}
let _dialogInstance = null;
export function showModelContributionDialog(modelName = "") {
if (!_dialogInstance) {
_dialogInstance = new ModelContributionDialog();
}
_dialogInstance.show(modelName);
if (!_dialogInstance) {
_dialogInstance = new ModelContributionDialog();
}
_dialogInstance.show(modelName);
}
//# sourceMappingURL=model_contribution_dialog.js.map
File diff suppressed because one or more lines are too long
+11
View File
@@ -0,0 +1,11 @@
export declare function getKindOptions(): {
value: string;
label: string;
}[];
export declare function typeHintToKind(typeHint: any): any;
export declare function getAllowedExtensions(): string[];
export declare function isValidUrl(url: any): boolean;
export declare function extractFilenameFromUrl(url: any): string;
export declare function hasAllowedExtension(filename: any): boolean;
export declare function normalizeKey(value: any): string;
export declare function collectNoteRecipes(workflowJson: any): Map<any, any>;
+51 -59
View File
@@ -1,81 +1,73 @@
const KIND_OPTIONS = [
{ value: "checkpoints", label: "Checkpoint" },
{ value: "diffusion_models", label: "Diffusion Model" },
{ value: "loras", label: "LoRA" },
{ value: "vae", label: "VAE" },
{ value: "controlnet", label: "ControlNet" },
{ value: "text_encoders", label: "Text Encoder" },
{ value: "clip", label: "CLIP" },
{ value: "clip_vision", label: "CLIP Vision" },
{ value: "unet", label: "UNet" },
{ value: "upscale_models", label: "Upscale Model" },
{ value: "embeddings", label: "Embedding" },
{ value: "checkpoints", label: "Checkpoint" },
{ value: "diffusion_models", label: "Diffusion Model" },
{ value: "loras", label: "LoRA" },
{ value: "vae", label: "VAE" },
{ value: "controlnet", label: "ControlNet" },
{ value: "text_encoders", label: "Text Encoder" },
{ value: "clip", label: "CLIP" },
{ value: "clip_vision", label: "CLIP Vision" },
{ value: "unet", label: "UNet" },
{ value: "upscale_models", label: "Upscale Model" },
{ value: "embeddings", label: "Embedding" },
];
const TYPE_HINT_KIND = {
checkpoint: "checkpoints",
diffusion: "diffusion_models",
diffusion_models: "diffusion_models",
lora: "loras",
vae: "vae",
controlnet: "controlnet",
upscale_models: "upscale_models",
text_encoder: "text_encoders",
text_encoders: "text_encoders",
clip: "clip",
clip_vision: "clip_vision",
unet: "unet",
embeddings: "embeddings",
unknown: "",
checkpoint: "checkpoints",
diffusion: "diffusion_models",
diffusion_models: "diffusion_models",
lora: "loras",
vae: "vae",
controlnet: "controlnet",
upscale_models: "upscale_models",
text_encoder: "text_encoders",
text_encoders: "text_encoders",
clip: "clip",
clip_vision: "clip_vision",
unet: "unet",
embeddings: "embeddings",
unknown: "",
};
import { collectRecipesFromWorkflowNotes } from "./workflow_note_recipes.js";
const ALLOWED_EXTENSIONS = [".safetensors", ".ckpt", ".pt", ".pth", ".bin"];
export function getKindOptions() {
return KIND_OPTIONS.slice();
return KIND_OPTIONS.slice();
}
export function typeHintToKind(typeHint) {
const key = String(typeHint || "").toLowerCase();
return TYPE_HINT_KIND[key] || "";
const key = String(typeHint || "").toLowerCase();
return TYPE_HINT_KIND[key] || "";
}
export function getAllowedExtensions() {
return ALLOWED_EXTENSIONS.slice();
return ALLOWED_EXTENSIONS.slice();
}
export function isValidUrl(url) {
try {
const parsed = new URL(String(url || ""));
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch (_) {
return false;
}
try {
const parsed = new URL(String(url || ""));
return parsed.protocol === "http:" || parsed.protocol === "https:";
}
catch (_) {
return false;
}
}
export function extractFilenameFromUrl(url) {
try {
const parsed = new URL(String(url || ""));
const parts = parsed.pathname.split("/").filter(Boolean);
return parts.length ? parts[parts.length - 1] : "";
} catch (_) {
return "";
}
try {
const parsed = new URL(String(url || ""));
const parts = parsed.pathname.split("/").filter(Boolean);
return parts.length ? parts[parts.length - 1] : "";
}
catch (_) {
return "";
}
}
export function hasAllowedExtension(filename) {
const name = String(filename || "").toLowerCase();
return ALLOWED_EXTENSIONS.some((ext) => name.endsWith(ext));
const name = String(filename || "").toLowerCase();
return ALLOWED_EXTENSIONS.some((ext) => name.endsWith(ext));
}
export function normalizeKey(value) {
const text = String(value || "").replace(/\\/g, "/");
const base = text.split("/").pop() || "";
return base.split("?")[0].split("#")[0];
const text = String(value || "").replace(/\\/g, "/");
const base = text.split("/").pop() || "";
return base.split("?")[0].split("#")[0];
}
export function collectNoteRecipes(workflowJson) {
return collectRecipesFromWorkflowNotes(workflowJson);
return collectRecipesFromWorkflowNotes(workflowJson);
}
//# sourceMappingURL=model_downloader.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"model_downloader.js","sourceRoot":"","sources":["../../src/mjr/model_downloader.ts"],"names":[],"mappings":"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"}
+6
View File
@@ -0,0 +1,6 @@
export declare function scanMissingModelsFromGraph(): any[];
export declare function scanMissingModelsWithStats(): {
missing: any[];
total: number;
};
export declare function applyFixesToGraph(fixes: any): number;
+296 -290
View File
@@ -1,331 +1,337 @@
import { app } from "../../../scripts/app.js";
const MAX_MISSING = 200;
const VALUE_SET_THRESHOLD = 256;
// Widgets to exclude from model detection (images, etc.)
const IMAGE_WIDGET_NAMES = new Set([
"image",
"images",
"input_image",
"image_path",
"mask",
"background",
"foreground",
"image",
"images",
"input_image",
"image_path",
"mask",
"background",
"foreground",
]);
const FILE_WIDGET_NAME_HINTS = [
"image",
"video",
"audio",
"mask",
"path",
"file",
"filename",
"directory",
"folder",
"output",
"image",
"video",
"audio",
"mask",
"path",
"file",
"filename",
"directory",
"folder",
"output",
];
const FILE_EXTENSIONS = [
".png",
".jpg",
".jpeg",
".webp",
".gif",
".bmp",
".tiff",
".tif",
".mp4",
".webm",
".mov",
".mkv",
".avi",
".mp3",
".wav",
".flac",
".ogg",
".png",
".jpg",
".jpeg",
".webp",
".gif",
".bmp",
".tiff",
".tif",
".mp4",
".webm",
".mov",
".mkv",
".avi",
".mp3",
".wav",
".flac",
".ogg",
];
function isImageWidget(widgetName) {
const lower = String(widgetName || "").toLowerCase();
if (IMAGE_WIDGET_NAMES.has(lower)) return true;
if (lower.includes("png") || lower.includes("jpg") || lower.includes("jpeg")) return true;
return false;
const lower = String(widgetName || "").toLowerCase();
if (IMAGE_WIDGET_NAMES.has(lower))
return true;
if (lower.includes("png") || lower.includes("jpg") || lower.includes("jpeg"))
return true;
return false;
}
function looksLikeFileWidget(widgetName, value) {
const name = String(widgetName || "").toLowerCase();
if (FILE_WIDGET_NAME_HINTS.some((h) => name.includes(h))) return true;
const v = String(value || "").toLowerCase().trim();
return FILE_EXTENSIONS.some((ext) => v.endsWith(ext));
const name = String(widgetName || "").toLowerCase();
if (FILE_WIDGET_NAME_HINTS.some((h) => name.includes(h)))
return true;
const v = String(value || "").toLowerCase().trim();
return FILE_EXTENSIONS.some((ext) => v.endsWith(ext));
}
function typeHintFromNode(node, widgetName) {
const nodeType = String(node?.type || node?.title || "").toLowerCase();
const widgetLower = String(widgetName || "").toLowerCase();
const nodeHasClipVision =
nodeType.includes("clip_vision") ||
nodeType.includes("clip vision") ||
(nodeType.includes("clip") && nodeType.includes("vision"));
// Match based on node type (highest priority)
if (nodeHasClipVision) return "clip_vision";
if (nodeType.includes("checkpoint")) {
if (widgetLower.includes("vae")) return "vae";
if (widgetLower.includes("lora")) return "lora";
return "checkpoint";
}
if (nodeType.includes("loader")) {
if (widgetLower.includes("vae")) return "vae";
if (widgetLower.includes("lora")) return "lora";
if (widgetLower.includes("unet") || widgetLower.includes("diffusion")) return "unet";
const nodeType = String(node?.type || node?.title || "").toLowerCase();
const widgetLower = String(widgetName || "").toLowerCase();
const nodeHasClipVision = nodeType.includes("clip_vision") ||
nodeType.includes("clip vision") ||
(nodeType.includes("clip") && nodeType.includes("vision"));
// Match based on node type (highest priority)
if (nodeHasClipVision)
return "clip_vision";
if (nodeType.includes("checkpoint")) {
if (widgetLower.includes("vae"))
return "vae";
if (widgetLower.includes("lora"))
return "lora";
return "checkpoint";
}
if (nodeType.includes("loader")) {
if (widgetLower.includes("vae"))
return "vae";
if (widgetLower.includes("lora"))
return "lora";
if (widgetLower.includes("unet") || widgetLower.includes("diffusion"))
return "unet";
if (widgetLower.includes("text_encoder") || widgetLower.includes("text encoder"))
return "text_encoders";
if (widgetLower.includes("clip"))
return "clip";
}
if (nodeType.includes("lora"))
return "lora";
if (nodeType.includes("vae"))
return "vae";
if (nodeType.includes("controlnet") || nodeType.includes("control"))
return "controlnet";
if (nodeType.includes("upscale"))
return "upscale_models";
if (nodeType.includes("clip")) {
if (widgetLower.includes("vision"))
return "clip_vision";
return "clip";
}
if (nodeType.includes("text_encoder") || nodeType.includes("text encoder"))
return "text_encoders";
if (nodeType.includes("unet"))
return "unet";
if (nodeType.includes("embedding") || nodeType.includes("textual"))
return "embeddings";
// Match based on widget name (secondary priority)
if (widgetLower.includes("ckpt") || widgetLower.includes("checkpoint"))
return "checkpoint";
if (widgetLower.includes("lora"))
return "lora";
if (widgetLower.includes("vae"))
return "vae";
if (widgetLower.includes("controlnet") || widgetLower.includes("control"))
return "controlnet";
if (widgetLower.includes("upscale"))
return "upscale_models";
if (widgetLower.includes("clip")) {
if (widgetLower.includes("vision"))
return "clip_vision";
return "clip";
}
if (widgetLower.includes("text_encoder") || widgetLower.includes("text encoder"))
return "text_encoders";
if (widgetLower.includes("clip")) return "clip";
}
if (nodeType.includes("lora")) return "lora";
if (nodeType.includes("vae")) return "vae";
if (nodeType.includes("controlnet") || nodeType.includes("control")) return "controlnet";
if (nodeType.includes("upscale")) return "upscale_models";
if (nodeType.includes("clip")) {
if (widgetLower.includes("vision")) return "clip_vision";
return "clip";
}
if (nodeType.includes("text_encoder") || nodeType.includes("text encoder"))
return "text_encoders";
if (nodeType.includes("unet")) return "unet";
if (nodeType.includes("embedding") || nodeType.includes("textual")) return "embeddings";
// Match based on widget name (secondary priority)
if (widgetLower.includes("ckpt") || widgetLower.includes("checkpoint")) return "checkpoint";
if (widgetLower.includes("lora")) return "lora";
if (widgetLower.includes("vae")) return "vae";
if (widgetLower.includes("controlnet") || widgetLower.includes("control")) return "controlnet";
if (widgetLower.includes("upscale")) return "upscale_models";
if (widgetLower.includes("clip")) {
if (widgetLower.includes("vision")) return "clip_vision";
return "clip";
}
if (widgetLower.includes("text_encoder") || widgetLower.includes("text encoder"))
return "text_encoders";
if (widgetLower.includes("diffusion")) return "diffusion_models";
if (widgetLower.includes("unet")) return "unet";
if (widgetLower.includes("embedding")) return "embeddings";
return "unknown";
return "text_encoders";
if (widgetLower.includes("diffusion"))
return "diffusion_models";
if (widgetLower.includes("unet"))
return "unet";
if (widgetLower.includes("embedding"))
return "embeddings";
return "unknown";
}
function isModelWidget(node, widgetName, value) {
if (isImageWidget(widgetName)) return false;
if (looksLikeFileWidget(widgetName, value)) return false;
return typeHintFromNode(node, widgetName) !== "unknown";
if (isImageWidget(widgetName))
return false;
if (looksLikeFileWidget(widgetName, value))
return false;
return typeHintFromNode(node, widgetName) !== "unknown";
}
// Recursively collect all nodes including those in subgraphs and groups
function collectAllNodes(graph) {
const allNodes = [];
const visited = new WeakSet();
function collectFromGraph(g) {
if (!g || !Array.isArray(g._nodes)) return;
for (const node of g._nodes) {
if (!node || typeof node !== "object") continue;
if (visited.has(node)) continue;
visited.add(node);
allNodes.push(node);
// Check for subgraph
if (node.subgraph && typeof node.subgraph === "object") {
collectFromGraph(node.subgraph);
}
// Check for group nodes (some implementations)
if (node.graph && typeof node.graph === "object" && node.graph !== g) {
collectFromGraph(node.graph);
}
const allNodes = [];
const visited = new WeakSet();
function collectFromGraph(g) {
if (!g || !Array.isArray(g._nodes))
return;
for (const node of g._nodes) {
if (!node || typeof node !== "object")
continue;
if (visited.has(node))
continue;
visited.add(node);
allNodes.push(node);
// Check for subgraph
if (node.subgraph && typeof node.subgraph === "object") {
collectFromGraph(node.subgraph);
}
// Check for group nodes (some implementations)
if (node.graph && typeof node.graph === "object" && node.graph !== g) {
collectFromGraph(node.graph);
}
}
}
}
collectFromGraph(graph);
return allNodes;
collectFromGraph(graph);
return allNodes;
}
function _valuesContain(values, value, widget) {
if (!Array.isArray(values) || values.length === 0) return false;
const valueStr = String(value);
if (values.length < VALUE_SET_THRESHOLD) {
for (const item of values) {
if (String(item) === valueStr) return true;
if (!Array.isArray(values) || values.length === 0)
return false;
const valueStr = String(value);
if (values.length < VALUE_SET_THRESHOLD) {
for (const item of values) {
if (String(item) === valueStr)
return true;
}
return false;
}
return false;
}
if (!widget || typeof widget !== "object") {
const set = new Set(values.map((v) => String(v)));
return set.has(valueStr);
}
if (widget.__mjrValuesRef !== values || !(widget.__mjrValuesSet instanceof Set)) {
widget.__mjrValuesRef = values;
widget.__mjrValuesSet = new Set(values.map((v) => String(v)));
}
return widget.__mjrValuesSet.has(valueStr);
if (!widget || typeof widget !== "object") {
const set = new Set(values.map((v) => String(v)));
return set.has(valueStr);
}
if (widget.__mjrValuesRef !== values || !(widget.__mjrValuesSet instanceof Set)) {
widget.__mjrValuesRef = values;
widget.__mjrValuesSet = new Set(values.map((v) => String(v)));
}
return widget.__mjrValuesSet.has(valueStr);
}
export function scanMissingModelsFromGraph() {
return scanMissingModelsWithStats().missing;
return scanMissingModelsWithStats().missing;
}
export function scanMissingModelsWithStats() {
const graph = app?.graph;
if (!graph) return { missing: [], total: 0 };
// Collect all nodes including subgraphs and groups
const allNodes = collectAllNodes(graph);
const missing = [];
const nodesWithMissing = new Set();
let total = 0;
for (const node of allNodes) {
const widgets = node?.widgets || [];
for (const widget of widgets) {
const widgetName = widget?.name || "";
const values = widget?.options?.values;
if (!Array.isArray(values)) continue;
const value = widget?.value;
if (!value) continue;
// Only scan model selector widgets (skip image/video/file/path widgets, etc.)
if (!isModelWidget(node, widgetName, value)) continue;
total += 1;
if (_valuesContain(values, value, widget)) continue;
missing.push({
node_id: node?.id ?? null,
node_title: node?.title || node?.type || "",
widget_name: widgetName,
missing_value: String(value),
type_hint: typeHintFromNode(node, widgetName),
});
// Track nodes with missing models
if (node?.id != null) {
nodesWithMissing.add(node.id);
}
if (missing.length >= MAX_MISSING) break;
const graph = app?.graph;
if (!graph)
return { missing: [], total: 0 };
// Collect all nodes including subgraphs and groups
const allNodes = collectAllNodes(graph);
const missing = [];
const nodesWithMissing = new Set();
let total = 0;
for (const node of allNodes) {
const widgets = node?.widgets || [];
for (const widget of widgets) {
const widgetName = widget?.name || "";
const values = widget?.options?.values;
if (!Array.isArray(values))
continue;
const value = widget?.value;
if (!value)
continue;
// Only scan model selector widgets (skip image/video/file/path widgets, etc.)
if (!isModelWidget(node, widgetName, value))
continue;
total += 1;
if (_valuesContain(values, value, widget))
continue;
missing.push({
node_id: node?.id ?? null,
node_title: node?.title || node?.type || "",
widget_name: widgetName,
missing_value: String(value),
type_hint: typeHintFromNode(node, widgetName),
});
// Track nodes with missing models
if (node?.id != null) {
nodesWithMissing.add(node.id);
}
if (missing.length >= MAX_MISSING)
break;
}
if (missing.length >= MAX_MISSING)
break;
}
if (missing.length >= MAX_MISSING) break;
}
// Highlight nodes with missing models
highlightNodesWithMissingModels(allNodes, nodesWithMissing);
return { missing, total };
// Highlight nodes with missing models
highlightNodesWithMissingModels(allNodes, nodesWithMissing);
return { missing, total };
}
function highlightNodesWithMissingModels(allNodes, nodesWithMissing) {
for (const node of allNodes) {
if (!node) continue;
if (nodesWithMissing.has(node.id)) {
if (!node.__mjrMissingHighlight) {
node.__mjrMissingHighlight = true;
node.__mjrMissingColor = node.color ?? null;
node.__mjrMissingBgcolor = node.bgcolor ?? null;
}
// Highlight node with missing models (border only)
node.color = "#FF0000";
node.bgcolor = null;
} else if (node.__mjrMissingHighlight) {
// Restore original colors if no longer missing
node.color = node.__mjrMissingColor ?? null;
node.bgcolor = node.__mjrMissingBgcolor ?? null;
delete node.__mjrMissingHighlight;
delete node.__mjrMissingColor;
delete node.__mjrMissingBgcolor;
for (const node of allNodes) {
if (!node)
continue;
if (nodesWithMissing.has(node.id)) {
if (!node.__mjrMissingHighlight) {
node.__mjrMissingHighlight = true;
node.__mjrMissingColor = node.color ?? null;
node.__mjrMissingBgcolor = node.bgcolor ?? null;
}
// Highlight node with missing models (border only)
node.color = "#FF0000";
node.bgcolor = null;
}
else if (node.__mjrMissingHighlight) {
// Restore original colors if no longer missing
node.color = node.__mjrMissingColor ?? null;
node.bgcolor = node.__mjrMissingBgcolor ?? null;
delete node.__mjrMissingHighlight;
delete node.__mjrMissingColor;
delete node.__mjrMissingBgcolor;
}
}
// Force canvas redraw
if (typeof app?.graph?.setDirtyCanvas === "function") {
app.graph.setDirtyCanvas(true, true);
}
}
// Force canvas redraw
if (typeof app?.graph?.setDirtyCanvas === "function") {
app.graph.setDirtyCanvas(true, true);
}
}
export function applyFixesToGraph(fixes) {
const graph = app?.graph;
if (!graph) return 0;
// Collect all nodes including subgraphs and groups
const allNodes = collectAllNodes(graph);
const nodesById = new Map();
for (const node of allNodes) {
if (node && node.id != null) {
nodesById.set(node.id, node);
const graph = app?.graph;
if (!graph)
return 0;
// Collect all nodes including subgraphs and groups
const allNodes = collectAllNodes(graph);
const nodesById = new Map();
for (const node of allNodes) {
if (node && node.id != null) {
nodesById.set(node.id, node);
}
}
}
let fixed = 0;
const fixedNodeIds = new Set();
for (const fix of fixes || []) {
if (!fix) continue;
const node = nodesById.get(fix.node_id);
if (!node) continue;
const widgets = node?.widgets || [];
const widget = widgets.find((w) => w?.name === fix.widget_name);
if (!widget) continue;
widget.value = fix.new_value;
// Ensure widget callbacks fire so nodes update internal state.
if (typeof widget.callback === "function") {
try {
widget.callback(widget.value, app.graph, node, widget);
} catch (_) {}
let fixed = 0;
const fixedNodeIds = new Set();
for (const fix of fixes || []) {
if (!fix)
continue;
const node = nodesById.get(fix.node_id);
if (!node)
continue;
const widgets = node?.widgets || [];
const widget = widgets.find((w) => w?.name === fix.widget_name);
if (!widget)
continue;
widget.value = fix.new_value;
// Ensure widget callbacks fire so nodes update internal state.
if (typeof widget.callback === "function") {
try {
widget.callback(widget.value, app.graph, node, widget);
}
catch (_) { }
}
if (typeof node.onWidgetChanged === "function") {
try {
node.onWidgetChanged(widget.name, widget.value, widget);
}
catch (_) { }
}
if (typeof node.onPropertyChanged === "function") {
try {
node.onPropertyChanged(widget.name, widget.value, widget);
}
catch (_) { }
}
if (Array.isArray(node.widgets)) {
node.widgets_values = node.widgets.map((w) => w?.value);
}
if (typeof node.setDirtyCanvas === "function") {
node.setDirtyCanvas(true, true);
}
fixed += 1;
fixedNodeIds.add(fix.node_id);
}
if (typeof node.onWidgetChanged === "function") {
try {
node.onWidgetChanged(widget.name, widget.value, widget);
} catch (_) {}
// Clear highlighting on fixed nodes
for (const nodeId of fixedNodeIds) {
const node = nodesById.get(nodeId);
if (node && node.color === "#FF0000") {
node.color = null;
node.bgcolor = null;
}
}
if (typeof node.onPropertyChanged === "function") {
try {
node.onPropertyChanged(widget.name, widget.value, widget);
} catch (_) {}
if (fixed > 0 && typeof graph.setDirtyCanvas === "function") {
graph.setDirtyCanvas(true, true);
}
if (Array.isArray(node.widgets)) {
node.widgets_values = node.widgets.map((w) => w?.value);
if (fixed > 0 && typeof app?.refreshComboInNodes === "function") {
try {
app.refreshComboInNodes();
}
catch (_) { }
}
if (typeof node.setDirtyCanvas === "function") {
node.setDirtyCanvas(true, true);
}
fixed += 1;
fixedNodeIds.add(fix.node_id);
}
// Clear highlighting on fixed nodes
for (const nodeId of fixedNodeIds) {
const node = nodesById.get(nodeId);
if (node && node.color === "#FF0000") {
node.color = null;
node.bgcolor = null;
}
}
if (fixed > 0 && typeof graph.setDirtyCanvas === "function") {
graph.setDirtyCanvas(true, true);
}
if (fixed > 0 && typeof app?.refreshComboInNodes === "function") {
try {
app.refreshComboInNodes();
} catch (_) {}
}
return fixed;
return fixed;
}
//# sourceMappingURL=model_fixer.js.map
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
export declare let PATH_WIDGETS: any[];
export declare function loadConfig(): Promise<any>;
export declare function detectNodeMedia(node: any): "images" | "videos";
export declare function isSaveLikeNode(node: any): any;
export declare function alreadyProjectPathed(value: any): boolean;
export declare function patchSingleNode(node: any, relDir: any, filenamePrefix: any): boolean;
export declare function patchSaveNodes(app: any, relDir: any, filenamePrefix: any, targetMedia: any): Promise<number>;
export declare function stampGraphProjectSignature(app: any, state: any): void;
+88 -90
View File
@@ -1,107 +1,105 @@
import { fetchJSON } from "./api.js";
import { safeRel, joinRel } from "./utils.js";
export let PATH_WIDGETS = [];
let configPromise = null;
export async function loadConfig() {
if (configPromise) return configPromise;
configPromise = (async () => {
try {
const resp = await fetchJSON("/mjr_project/config");
const list = Array.isArray(resp?.path_widgets) ? resp.path_widgets : [];
PATH_WIDGETS = list.map((item) => String(item)).filter(Boolean);
return PATH_WIDGETS;
} catch (err) {
console.error("[mjr] Failed to load config:", err);
return PATH_WIDGETS;
} finally {
configPromise = null;
}
})();
return configPromise;
if (configPromise)
return configPromise;
configPromise = (async () => {
try {
const resp = await fetchJSON("/mjr_project/config");
const list = Array.isArray(resp?.path_widgets) ? resp.path_widgets : [];
PATH_WIDGETS = list.map((item) => String(item)).filter(Boolean);
return PATH_WIDGETS;
}
catch (err) {
console.error("[mjr] Failed to load config:", err);
return PATH_WIDGETS;
}
finally {
configPromise = null;
}
})();
return configPromise;
}
export function detectNodeMedia(node) {
const t = String(node?.comfyClass || node?.type || "").toLowerCase();
if (t.includes("video") || t.includes("mp4") || t.includes("gif")) return "videos";
if (t.includes("image") || t.includes("png") || t.includes("jpg") || t.includes("webp"))
return "images";
const widgets = node?.widgets || [];
const hasVideoHints = widgets.some((w) =>
["frame_rate", "fps", "duration", "frames_per_second"].includes(String(w?.name || ""))
);
return hasVideoHints ? "videos" : "images";
const t = String(node?.comfyClass || node?.type || "").toLowerCase();
if (t.includes("video") || t.includes("mp4") || t.includes("gif"))
return "videos";
if (t.includes("image") || t.includes("png") || t.includes("jpg") || t.includes("webp"))
return "images";
const widgets = node?.widgets || [];
const hasVideoHints = widgets.some((w) => ["frame_rate", "fps", "duration", "frames_per_second"].includes(String(w?.name || "")));
return hasVideoHints ? "videos" : "images";
}
export function isSaveLikeNode(node) {
const t = String(node?.comfyClass || node?.type || "").toLowerCase();
if (!t.includes("save") && !t.includes("export") && !t.includes("combine")) return false;
const widgets = node?.widgets || [];
const hasFilename = widgets.some((w) => w?.name === "filename_prefix");
const hasPath = widgets.some((w) => w?.name && PATH_WIDGETS.includes(w.name));
return hasFilename || hasPath;
const t = String(node?.comfyClass || node?.type || "").toLowerCase();
if (!t.includes("save") && !t.includes("export") && !t.includes("combine"))
return false;
const widgets = node?.widgets || [];
const hasFilename = widgets.some((w) => w?.name === "filename_prefix");
const hasPath = widgets.some((w) => w?.name && PATH_WIDGETS.includes(w.name));
return hasFilename || hasPath;
}
export function alreadyProjectPathed(value) {
if (!value) return false;
const s = String(value).replace(/\\/g, "/");
return s.includes("PROJECTS/") || s.includes("02_OUT/") || s.includes("00_META/");
if (!value)
return false;
const s = String(value).replace(/\\/g, "/");
return s.includes("PROJECTS/") || s.includes("02_OUT/") || s.includes("00_META/");
}
export function patchSingleNode(node, relDir, filenamePrefix) {
if (!isSaveLikeNode(node)) return false;
const rel = safeRel(relDir);
const prefix = safeRel(filenamePrefix);
const widgets = node?.widgets || [];
const filenameWidget = widgets.find((w) => w?.name === "filename_prefix");
const pathWidget = widgets.find((w) => w?.name && PATH_WIDGETS.includes(w.name));
if (pathWidget && filenameWidget) {
pathWidget.value = rel;
filenameWidget.value = prefix;
return true;
}
if (filenameWidget) {
filenameWidget.value = prefix ? joinRel(rel, prefix) : rel;
return true;
}
if (pathWidget) {
pathWidget.value = rel;
return true;
}
return false;
}
export async function patchSaveNodes(app, relDir, filenamePrefix, targetMedia) {
await loadConfig();
const nodes = app?.graph?._nodes || [];
let patched = 0;
for (const node of nodes) {
const nm = detectNodeMedia(node);
if (targetMedia && nm && nm !== targetMedia) continue;
if (patchSingleNode(node, relDir, filenamePrefix)) {
patched += 1;
if (!isSaveLikeNode(node))
return false;
const rel = safeRel(relDir);
const prefix = safeRel(filenamePrefix);
const widgets = node?.widgets || [];
const filenameWidget = widgets.find((w) => w?.name === "filename_prefix");
const pathWidget = widgets.find((w) => w?.name && PATH_WIDGETS.includes(w.name));
if (pathWidget && filenameWidget) {
pathWidget.value = rel;
filenameWidget.value = prefix;
return true;
}
}
try {
app.graph.setDirtyCanvas(true, true);
} catch (_) {}
return patched;
if (filenameWidget) {
filenameWidget.value = prefix ? joinRel(rel, prefix) : rel;
return true;
}
if (pathWidget) {
pathWidget.value = rel;
return true;
}
return false;
}
export async function patchSaveNodes(app, relDir, filenamePrefix, targetMedia) {
await loadConfig();
const nodes = app?.graph?._nodes || [];
let patched = 0;
for (const node of nodes) {
const nm = detectNodeMedia(node);
if (targetMedia && nm && nm !== targetMedia)
continue;
if (patchSingleNode(node, relDir, filenamePrefix)) {
patched += 1;
}
}
try {
app.graph.setDirtyCanvas(true, true);
}
catch (_) { }
return patched;
}
export function stampGraphProjectSignature(app, state) {
if (!state?.projectId || !state?.projectFolder) return;
const g = app?.graph;
if (!g) return;
g.extra = g.extra || {};
g.extra.mjr_project = {
project_id: state.projectId,
project_folder: state.projectFolder,
template: state.pathTemplate || "",
updated_at: new Date().toISOString(),
};
if (!state?.projectId || !state?.projectFolder)
return;
const g = app?.graph;
if (!g)
return;
g.extra = g.extra || {};
g.extra.mjr_project = {
project_id: state.projectId,
project_folder: state.projectFolder,
template: state.pathTemplate || "",
updated_at: new Date().toISOString(),
};
}
//# sourceMappingURL=patch.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"patch.js","sourceRoot":"","sources":["../../src/mjr/patch.ts"],"names":[],"mappings":"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"}
+2
View File
@@ -0,0 +1,2 @@
export declare function ensureStyles(): void;
export declare function toast(type: any, title: any, message: any, opts?: {}): void;
+77 -78
View File
@@ -1,10 +1,10 @@
import { app } from "../../../scripts/app.js";
export function ensureStyles() {
if (document.getElementById("mjr-project-settings-style")) return;
const style = document.createElement("style");
style.id = "mjr-project-settings-style";
style.textContent = `
if (document.getElementById("mjr-project-settings-style"))
return;
const style = document.createElement("style");
style.id = "mjr-project-settings-style";
style.textContent = `
.mjr-ps-btn {
display: inline-flex;
align-items: center;
@@ -46,81 +46,80 @@ export function ensureStyles() {
color: #f2f4f8 !important;
}
`;
document.head.appendChild(style);
document.head.appendChild(style);
}
let toastContainer = null;
function getToastContainer() {
if (toastContainer && document.body.contains(toastContainer)) return toastContainer;
let el = document.getElementById("mjr-ps-toast-container");
if (!el) {
el = document.createElement("div");
el.id = "mjr-ps-toast-container";
el.style.position = "fixed";
el.style.top = "16px";
el.style.right = "16px";
el.style.zIndex = "10000";
el.style.display = "flex";
el.style.flexDirection = "column";
el.style.gap = "8px";
el.style.pointerEvents = "none";
document.body.appendChild(el);
}
toastContainer = el;
return el;
}
export function toast(type, title, message, opts = {}) {
const map = { success: "success", info: "info", warn: "warn", error: "error" };
const severity = map[type] || "info";
try {
const mgr = app?.extensionManager?.toast;
if (mgr?.add) {
mgr.add({
severity,
summary: title || "",
detail: message || "",
life: Number(opts.life || 4000),
});
return;
if (toastContainer && document.body.contains(toastContainer))
return toastContainer;
let el = document.getElementById("mjr-ps-toast-container");
if (!el) {
el = document.createElement("div");
el.id = "mjr-ps-toast-container";
el.style.position = "fixed";
el.style.top = "16px";
el.style.right = "16px";
el.style.zIndex = "10000";
el.style.display = "flex";
el.style.flexDirection = "column";
el.style.gap = "8px";
el.style.pointerEvents = "none";
document.body.appendChild(el);
}
} catch (_) {}
try {
const container = getToastContainer();
const item = document.createElement("div");
const colors = {
success: "rgba(46, 143, 78, 0.9)",
info: "rgba(46, 136, 255, 0.9)",
warn: "rgba(199, 124, 42, 0.9)",
error: "rgba(179, 58, 58, 0.9)",
};
item.style.background = colors[severity] || colors.info;
item.style.color = "#f2f4f8";
item.style.padding = "8px 10px";
item.style.borderRadius = "8px";
item.style.boxShadow = "0 6px 16px rgba(0,0,0,0.3)";
item.style.maxWidth = "320px";
item.style.pointerEvents = "none";
item.style.backdropFilter = "blur(6px)";
const strong = document.createElement("div");
strong.textContent = title || "";
strong.style.fontWeight = "600";
strong.style.marginBottom = message ? "2px" : "0";
const detail = document.createElement("div");
detail.textContent = message || "";
detail.style.opacity = "0.9";
detail.style.fontSize = "12px";
item.appendChild(strong);
if (message) item.appendChild(detail);
container.appendChild(item);
const life = Number(opts.life || 4000);
setTimeout(() => {
if (item.parentNode) item.remove();
}, life);
} catch (_) {}
toastContainer = el;
return el;
}
export function toast(type, title, message, opts = {}) {
const map = { success: "success", info: "info", warn: "warn", error: "error" };
const severity = map[type] || "info";
try {
const mgr = app?.extensionManager?.toast;
if (mgr?.add) {
mgr.add({
severity,
summary: title || "",
detail: message || "",
life: Number(opts.life || 4000),
});
return;
}
}
catch (_) { }
try {
const container = getToastContainer();
const item = document.createElement("div");
const colors = {
success: "rgba(46, 143, 78, 0.9)",
info: "rgba(46, 136, 255, 0.9)",
warn: "rgba(199, 124, 42, 0.9)",
error: "rgba(179, 58, 58, 0.9)",
};
item.style.background = colors[severity] || colors.info;
item.style.color = "#f2f4f8";
item.style.padding = "8px 10px";
item.style.borderRadius = "8px";
item.style.boxShadow = "0 6px 16px rgba(0,0,0,0.3)";
item.style.maxWidth = "320px";
item.style.pointerEvents = "none";
item.style.backdropFilter = "blur(6px)";
const strong = document.createElement("div");
strong.textContent = title || "";
strong.style.fontWeight = "600";
strong.style.marginBottom = message ? "2px" : "0";
const detail = document.createElement("div");
detail.textContent = message || "";
detail.style.opacity = "0.9";
detail.style.fontSize = "12px";
item.appendChild(strong);
if (message)
item.appendChild(detail);
container.appendChild(item);
const life = Number(opts.life || 4000);
setTimeout(() => {
if (item.parentNode)
item.remove();
}, life);
}
catch (_) { }
}
//# sourceMappingURL=toast.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"toast.js","sourceRoot":"","sources":["../../src/mjr/toast.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,GAAG,EAAE,MAAM,yBAAyB,CAAC;AAE9C,MAAM,UAAU,YAAY;IAC1B,IAAI,QAAQ,CAAC,cAAc,CAAC,4BAA4B,CAAC;QAAE,OAAO;IAClE,MAAM,KAAK,GAAG,QAAQ,CAAC,aAAa,CAAC,OAAO,CAAC,CAAC;IAC9C,KAAK,CAAC,EAAE,GAAG,4BAA4B,CAAC;IACxC,KAAK,CAAC,WAAW,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyCrB,CAAC;IACA,QAAQ,CAAC,IAAI,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC;AACnC,CAAC;AAED,IAAI,cAAc,GAAG,IAAI,CAAC;AAE1B,SAAS,iBAAiB;IACxB,IAAI,cAAc,IAAI,QAAQ,CAAC,IAAI,CAAC,QAAQ,CAAC,cAAc,CAAC;QAAE,OAAO,cAAc,CAAC;IACpF,IAAI,EAAE,GAAG,QAAQ,CAAC,cAAc,CAAC,wBAAwB,CAAC,CAAC;IAC3D,IAAI,CAAC,EAAE,EAAE,CAAC;QACR,EAAE,GAAG,QAAQ,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;QACnC,EAAE,CAAC,EAAE,GAAG,wBAAwB,CAAC;QACjC,EAAE,CAAC,KAAK,CAAC,QAAQ,GAAG,OAAO,CAAC;QAC5B,EAAE,CAAC,KAAK,CAAC,GAAG,GAAG,MAAM,CAAC;QACtB,EAAE,CAAC,KAAK,CAAC,KAAK,GAAG,MAAM,CAAC;QACxB,EAAE,CAAC,KAAK,CAAC,MAAM,GAAG,OAAO,CAAC;QAC1B,EAAE,CAAC,KAAK,CAAC,OAAO,GAAG,MAAM,CAAC;QAC1B,EAAE,CAAC,KAAK,CAAC,aAAa,GAAG,QAAQ,CAAC;QAClC,EAAE,CAAC,KAAK,CAAC,GAAG,GAAG,KAAK,CAAC;QACrB,EAAE,CAAC,KAAK,CAAC,aAAa,GAAG,MAAM,CAAC;QAChC,QAAQ,CAAC,IAAI,CAAC,WAAW,CAAC,EAAE,CAAC,CAAC;IAChC,CAAC;IACD,cAAc,GAAG,EAAE,CAAC;IACpB,OAAO,EAAE,CAAC;AACZ,CAAC;AAED,MAAM,UAAU,KAAK,CAAC,IAAI,EAAE,KAAK,EAAE,OAAO,EAAE,IAAI,GAAG,EAAE;IACnD,MAAM,GAAG,GAAG,EAAE,OAAO,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO,EAAE,CAAC;IAC/E,MAAM,QAAQ,GAAG,GAAG,CAAC,IAAI,CAAC,IAAI,MAAM,CAAC;IACrC,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,GAAG,EAAE,gBAAgB,EAAE,KAAK,CAAC;QACzC,IAAI,GAAG,EAAE,GAAG,EAAE,CAAC;YACb,GAAG,CAAC,GAAG,CAAC;gBACN,QAAQ;gBACR,OAAO,EAAE,KAAK,IAAI,EAAE;gBACpB,MAAM,EAAE,OAAO,IAAI,EAAE;gBACrB,IAAI,EAAE,MAAM,CAAC,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC;aAChC,CAAC,CAAC;YACH,OAAO;QACT,CAAC;IACH,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC,CAAA,CAAC;IAEd,IAAI,CAAC;QACH,MAAM,SAAS,GAAG,iBAAiB,EAAE,CAAC;QACtC,MAAM,IAAI,GAAG,QAAQ,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;QAC3C,MAAM,MAAM,GAAG;YACb,OAAO,EAAE,wBAAwB;YACjC,IAAI,EAAE,yBAAyB;YAC/B,IAAI,EAAE,yBAAyB;YAC/B,KAAK,EAAE,wBAAwB;SAChC,CAAC;QACF,IAAI,CAAC,KAAK,CAAC,UAAU,GAAG,MAAM,CAAC,QAAQ,CAAC,IAAI,MAAM,CAAC,IAAI,CAAC;QACxD,IAAI,CAAC,KAAK,CAAC,KAAK,GAAG,SAAS,CAAC;QAC7B,IAAI,CAAC,KAAK,CAAC,OAAO,GAAG,UAAU,CAAC;QAChC,IAAI,CAAC,KAAK,CAAC,YAAY,GAAG,KAAK,CAAC;QAChC,IAAI,CAAC,KAAK,CAAC,SAAS,GAAG,4BAA4B,CAAC;QACpD,IAAI,CAAC,KAAK,CAAC,QAAQ,GAAG,OAAO,CAAC;QAC9B,IAAI,CAAC,KAAK,CAAC,aAAa,GAAG,MAAM,CAAC;QAClC,IAAI,CAAC,KAAK,CAAC,cAAc,GAAG,WAAW,CAAC;QAExC,MAAM,MAAM,GAAG,QAAQ,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;QAC7C,MAAM,CAAC,WAAW,GAAG,KAAK,IAAI,EAAE,CAAC;QACjC,MAAM,CAAC,KAAK,CAAC,UAAU,GAAG,KAAK,CAAC;QAChC,MAAM,CAAC,KAAK,CAAC,YAAY,GAAG,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC;QAClD,MAAM,MAAM,GAAG,QAAQ,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;QAC7C,MAAM,CAAC,WAAW,GAAG,OAAO,IAAI,EAAE,CAAC;QACnC,MAAM,CAAC,KAAK,CAAC,OAAO,GAAG,KAAK,CAAC;QAC7B,MAAM,CAAC,KAAK,CAAC,QAAQ,GAAG,MAAM,CAAC;QAE/B,IAAI,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC;QACzB,IAAI,OAAO;YAAE,IAAI,CAAC,WAAW,CAAC,MAAM,CAAC,CAAC;QACtC,SAAS,CAAC,WAAW,CAAC,IAAI,CAAC,CAAC;QAE5B,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,IAAI,IAAI,IAAI,CAAC,CAAC;QACvC,UAAU,CAAC,GAAG,EAAE;YACd,IAAI,IAAI,CAAC,UAAU;gBAAE,IAAI,CAAC,MAAM,EAAE,CAAC;QACrC,CAAC,EAAE,IAAI,CAAC,CAAC;IACX,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC,CAAA,CAAC;AAChB,CAAC"}
+1
View File
@@ -0,0 +1 @@
export declare function showModelDownloaderDialog({ entries, kindOptions, existingMap }?: {}): Promise<unknown>;
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
export declare function showModelFixerDialog({ missing, results }?: {}): Promise<unknown>;
+294 -324
View File
@@ -1,335 +1,305 @@
import { ensureStyles } from "./toast.js";
function buildResultMap(results) {
const map = new Map();
for (const entry of results || []) {
const missingValue = String(entry?.missing_value || "");
const typeHint = String(entry?.type_hint || "unknown");
const key = `${missingValue}::${typeHint}`;
map.set(key, {
candidates: Array.isArray(entry?.candidates) ? entry.candidates : [],
exact_match_wrong_folder: entry?.exact_match_wrong_folder || null,
});
}
return map;
const map = new Map();
for (const entry of results || []) {
const missingValue = String(entry?.missing_value || "");
const typeHint = String(entry?.type_hint || "unknown");
const key = `${missingValue}::${typeHint}`;
map.set(key, {
candidates: Array.isArray(entry?.candidates) ? entry.candidates : [],
exact_match_wrong_folder: entry?.exact_match_wrong_folder || null,
});
}
return map;
}
function formatCandidateLabel(candidate) {
const score = Number(candidate?.score || 0);
const basename = String(candidate?.basename || candidate?.relpath || "");
const kind = String(candidate?.kind || "");
if (kind) {
return `${score} | ${basename} (${kind})`;
}
return `${score} | ${basename}`;
const score = Number(candidate?.score || 0);
const basename = String(candidate?.basename || candidate?.relpath || "");
const kind = String(candidate?.kind || "");
if (kind) {
return `${score} | ${basename} (${kind})`;
}
return `${score} | ${basename}`;
}
export function showModelFixerDialog({ missing, results } = {}) {
ensureStyles();
const missingList = Array.isArray(missing) ? missing : [];
const resultMap = buildResultMap(results);
return new Promise((resolve) => {
const overlay = document.createElement("div");
overlay.style.position = "fixed";
overlay.style.inset = "0";
overlay.style.background = "rgba(5, 6, 10, 0.75)";
overlay.style.zIndex = "10010";
overlay.style.display = "flex";
overlay.style.alignItems = "center";
overlay.style.justifyContent = "center";
overlay.style.padding = "16px";
const panel = document.createElement("div");
panel.style.width = "min(640px, 92vw)";
panel.style.maxHeight = "80vh";
panel.style.overflow = "auto";
panel.style.background = "rgba(18, 20, 26, 0.95)";
panel.style.border = "1px solid rgba(255,255,255,0.12)";
panel.style.borderRadius = "12px";
panel.style.padding = "14px 16px";
panel.style.display = "flex";
panel.style.flexDirection = "column";
panel.style.gap = "10px";
panel.style.color = "#eee";
panel.style.fontSize = "12px";
panel.style.boxShadow = "0 12px 32px rgba(0,0,0,0.4)";
overlay.appendChild(panel);
const title = document.createElement("div");
title.textContent = "Fix Missing Models";
title.style.fontWeight = "600";
title.style.fontSize = "13px";
title.style.letterSpacing = "0.4px";
panel.appendChild(title);
const subtitle = document.createElement("div");
subtitle.textContent = `Missing entries: ${missingList.length}`;
subtitle.style.opacity = "0.8";
subtitle.style.fontSize = "11px";
panel.appendChild(subtitle);
const list = document.createElement("div");
list.style.display = "flex";
list.style.flexDirection = "column";
list.style.gap = "8px";
panel.appendChild(list);
const rows = [];
for (const entry of missingList) {
const missingValue = String(entry?.missing_value || "");
const typeHint = String(entry?.type_hint || "unknown");
const key = `${missingValue}::${typeHint}`;
const resultData = resultMap.get(key) || { candidates: [], exact_match_wrong_folder: null };
const candidates = resultData.candidates || [];
const wrongFolderMatch = resultData.exact_match_wrong_folder;
const exactMatches = candidates.filter((c) => Number(c?.score || 0) === 100);
const exactUnique = exactMatches.length === 1;
const row = document.createElement("div");
row.style.display = "flex";
row.style.flexDirection = "column";
row.style.gap = "6px";
row.style.padding = "8px";
row.style.borderRadius = "8px";
row.style.border = "1px solid rgba(255,255,255,0.08)";
row.style.background = "rgba(12, 14, 18, 0.6)";
const label = document.createElement("div");
label.style.display = "flex";
label.style.alignItems = "center";
label.style.gap = "6px";
const titleText = document.createElement("div");
const nodeLabel = entry?.node_title || `Node ${entry?.node_id ?? "?"}`;
const widgetLabel = entry?.widget_name || "widget";
titleText.textContent = `${nodeLabel} - ${widgetLabel}`;
titleText.style.fontWeight = "600";
const autoTag = document.createElement("span");
if (exactUnique) {
autoTag.textContent = "auto";
autoTag.style.fontSize = "10px";
autoTag.style.padding = "1px 6px";
autoTag.style.borderRadius = "999px";
autoTag.style.background = "rgba(46, 136, 255, 0.25)";
autoTag.style.border = "1px solid rgba(46, 136, 255, 0.45)";
autoTag.style.color = "#cfe3ff";
}
label.appendChild(titleText);
if (exactUnique) {
label.appendChild(autoTag);
}
const missingLine = document.createElement("div");
missingLine.textContent = `Missing: ${missingValue}`;
missingLine.style.opacity = "0.8";
missingLine.style.fontSize = "11px";
// Show "Move to correct folder" button if exact match found in wrong folder
let moveButton = null;
if (wrongFolderMatch) {
const moveContainer = document.createElement("div");
moveContainer.style.display = "flex";
moveContainer.style.alignItems = "center";
moveContainer.style.gap = "8px";
moveContainer.style.padding = "6px 8px";
moveContainer.style.borderRadius = "8px";
moveContainer.style.background = "rgba(255, 165, 0, 0.15)";
moveContainer.style.border = "1px solid rgba(255, 165, 0, 0.3)";
const moveInfo = document.createElement("div");
moveInfo.style.flex = "1";
moveInfo.style.fontSize = "11px";
moveInfo.style.color = "#ffb74d";
moveInfo.textContent = `Found in ${wrongFolderMatch.kind}/ → Move to ${wrongFolderMatch.expected_kind}/`;
moveButton = document.createElement("button");
moveButton.textContent = "Move";
moveButton.className = "mjr-ps-btn";
moveButton.style.padding = "4px 10px";
moveButton.style.borderRadius = "6px";
moveButton.style.border = "1px solid rgba(255, 165, 0, 0.5)";
moveButton.style.background = "rgba(255, 165, 0, 0.25)";
moveButton.style.color = "#ffcc80";
moveButton.style.cursor = "pointer";
moveButton.style.fontSize = "11px";
moveButton.dataset.sourceKind = wrongFolderMatch.kind;
moveButton.dataset.sourceRelpath = wrongFolderMatch.relpath;
moveButton.dataset.targetKind = wrongFolderMatch.expected_kind;
moveContainer.appendChild(moveInfo);
moveContainer.appendChild(moveButton);
row.appendChild(label);
row.appendChild(missingLine);
row.appendChild(moveContainer);
} else {
row.appendChild(label);
row.appendChild(missingLine);
}
const select = document.createElement("select");
select.className = "mjr-ps-select";
select.style.width = "100%";
select.style.padding = "6px 8px";
select.style.borderRadius = "8px";
select.style.border = "1px solid rgba(255,255,255,0.12)";
select.style.color = "#f2f4f8";
const emptyOpt = document.createElement("option");
emptyOpt.value = "";
emptyOpt.textContent = candidates.length ? "-- Select match --" : "No candidates found";
select.appendChild(emptyOpt);
for (const candidate of candidates) {
const opt = document.createElement("option");
opt.value = String(candidate?.relpath || "");
opt.textContent = formatCandidateLabel(candidate);
opt.title = String(candidate?.relpath || "");
select.appendChild(opt);
}
if (exactUnique) {
select.value = String(exactMatches[0]?.relpath || "");
}
if (!candidates.length) {
select.disabled = true;
select.style.opacity = "0.6";
}
row.appendChild(select);
list.appendChild(row);
rows.push({
node_id: entry?.node_id,
widget_name: entry?.widget_name,
select,
moveButton,
});
}
const actions = document.createElement("div");
actions.style.display = "flex";
actions.style.gap = "8px";
actions.style.justifyContent = "flex-end";
actions.style.marginTop = "6px";
const cancelBtn = document.createElement("button");
cancelBtn.textContent = "Cancel";
cancelBtn.className = "mjr-ps-btn";
cancelBtn.style.padding = "6px 10px";
cancelBtn.style.borderRadius = "8px";
cancelBtn.style.border = "1px solid rgba(255,255,255,0.2)";
cancelBtn.style.background = "rgba(255,255,255,0.06)";
cancelBtn.style.color = "#eee";
cancelBtn.style.cursor = "pointer";
const applyBtn = document.createElement("button");
applyBtn.textContent = "Apply Fix";
applyBtn.className = "mjr-ps-btn";
applyBtn.style.padding = "6px 10px";
applyBtn.style.borderRadius = "8px";
applyBtn.style.border = "1px solid rgba(46, 136, 255, 0.65)";
applyBtn.style.background = "rgba(46, 136, 255, 0.25)";
applyBtn.style.color = "#eee";
applyBtn.style.cursor = "pointer";
actions.appendChild(cancelBtn);
actions.appendChild(applyBtn);
panel.appendChild(actions);
const close = (value) => {
document.removeEventListener("keydown", onKeyDown);
if (overlay.parentNode) overlay.remove();
resolve(value);
};
const onKeyDown = (event) => {
if (String(event.key || "").toLowerCase() === "escape") {
event.preventDefault();
close(null);
}
};
// Add event listeners to all move buttons
for (const row of rows) {
if (row.moveButton) {
row.moveButton.addEventListener("click", async () => {
const button = row.moveButton;
const sourceKind = button.dataset.sourceKind;
const sourceRelpath = button.dataset.sourceRelpath;
const targetKind = button.dataset.targetKind;
button.disabled = true;
button.textContent = "Moving...";
try {
const response = await fetch("/mjr_models/move_to_correct_folder", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
source_kind: sourceKind,
source_relpath: sourceRelpath,
target_kind: targetKind,
}),
});
const data = await response.json();
if (data.ok) {
button.textContent = "Moved!";
button.style.background = "rgba(76, 175, 80, 0.25)";
button.style.border = "1px solid rgba(76, 175, 80, 0.5)";
button.style.color = "#a5d6a7";
// Auto-select the newly moved file in the dropdown
if (row.select && data.target_relpath) {
row.select.value = data.target_relpath;
}
// Refresh the folder to show the new file
setTimeout(() => {
if (typeof app?.refreshComboInNodes === "function") {
app.refreshComboInNodes();
}
}, 500);
} else {
throw new Error(data.error || "Move failed");
ensureStyles();
const missingList = Array.isArray(missing) ? missing : [];
const resultMap = buildResultMap(results);
return new Promise((resolve) => {
const overlay = document.createElement("div");
overlay.style.position = "fixed";
overlay.style.inset = "0";
overlay.style.background = "rgba(5, 6, 10, 0.75)";
overlay.style.zIndex = "10010";
overlay.style.display = "flex";
overlay.style.alignItems = "center";
overlay.style.justifyContent = "center";
overlay.style.padding = "16px";
const panel = document.createElement("div");
panel.style.width = "min(640px, 92vw)";
panel.style.maxHeight = "80vh";
panel.style.overflow = "auto";
panel.style.background = "rgba(18, 20, 26, 0.95)";
panel.style.border = "1px solid rgba(255,255,255,0.12)";
panel.style.borderRadius = "12px";
panel.style.padding = "14px 16px";
panel.style.display = "flex";
panel.style.flexDirection = "column";
panel.style.gap = "10px";
panel.style.color = "#eee";
panel.style.fontSize = "12px";
panel.style.boxShadow = "0 12px 32px rgba(0,0,0,0.4)";
overlay.appendChild(panel);
const title = document.createElement("div");
title.textContent = "Fix Missing Models";
title.style.fontWeight = "600";
title.style.fontSize = "13px";
title.style.letterSpacing = "0.4px";
panel.appendChild(title);
const subtitle = document.createElement("div");
subtitle.textContent = `Missing entries: ${missingList.length}`;
subtitle.style.opacity = "0.8";
subtitle.style.fontSize = "11px";
panel.appendChild(subtitle);
const list = document.createElement("div");
list.style.display = "flex";
list.style.flexDirection = "column";
list.style.gap = "8px";
panel.appendChild(list);
const rows = [];
for (const entry of missingList) {
const missingValue = String(entry?.missing_value || "");
const typeHint = String(entry?.type_hint || "unknown");
const key = `${missingValue}::${typeHint}`;
const resultData = resultMap.get(key) || { candidates: [], exact_match_wrong_folder: null };
const candidates = resultData.candidates || [];
const wrongFolderMatch = resultData.exact_match_wrong_folder;
const exactMatches = candidates.filter((c) => Number(c?.score || 0) === 100);
const exactUnique = exactMatches.length === 1;
const row = document.createElement("div");
row.style.display = "flex";
row.style.flexDirection = "column";
row.style.gap = "6px";
row.style.padding = "8px";
row.style.borderRadius = "8px";
row.style.border = "1px solid rgba(255,255,255,0.08)";
row.style.background = "rgba(12, 14, 18, 0.6)";
const label = document.createElement("div");
label.style.display = "flex";
label.style.alignItems = "center";
label.style.gap = "6px";
const titleText = document.createElement("div");
const nodeLabel = entry?.node_title || `Node ${entry?.node_id ?? "?"}`;
const widgetLabel = entry?.widget_name || "widget";
titleText.textContent = `${nodeLabel} - ${widgetLabel}`;
titleText.style.fontWeight = "600";
const autoTag = document.createElement("span");
if (exactUnique) {
autoTag.textContent = "auto";
autoTag.style.fontSize = "10px";
autoTag.style.padding = "1px 6px";
autoTag.style.borderRadius = "999px";
autoTag.style.background = "rgba(46, 136, 255, 0.25)";
autoTag.style.border = "1px solid rgba(46, 136, 255, 0.45)";
autoTag.style.color = "#cfe3ff";
}
} catch (error) {
console.error("[mjr] Failed to move model:", error);
button.textContent = "Error";
button.style.background = "rgba(244, 67, 54, 0.25)";
button.style.border = "1px solid rgba(244, 67, 54, 0.5)";
button.disabled = false;
setTimeout(() => {
button.textContent = "Move";
button.style.background = "rgba(255, 165, 0, 0.25)";
button.style.border = "1px solid rgba(255, 165, 0, 0.5)";
}, 3000);
}
label.appendChild(titleText);
if (exactUnique) {
label.appendChild(autoTag);
}
const missingLine = document.createElement("div");
missingLine.textContent = `Missing: ${missingValue}`;
missingLine.style.opacity = "0.8";
missingLine.style.fontSize = "11px";
// Show "Move to correct folder" button if exact match found in wrong folder
let moveButton = null;
if (wrongFolderMatch) {
const moveContainer = document.createElement("div");
moveContainer.style.display = "flex";
moveContainer.style.alignItems = "center";
moveContainer.style.gap = "8px";
moveContainer.style.padding = "6px 8px";
moveContainer.style.borderRadius = "8px";
moveContainer.style.background = "rgba(255, 165, 0, 0.15)";
moveContainer.style.border = "1px solid rgba(255, 165, 0, 0.3)";
const moveInfo = document.createElement("div");
moveInfo.style.flex = "1";
moveInfo.style.fontSize = "11px";
moveInfo.style.color = "#ffb74d";
moveInfo.textContent = `Found in ${wrongFolderMatch.kind}/ → Move to ${wrongFolderMatch.expected_kind}/`;
moveButton = document.createElement("button");
moveButton.textContent = "Move";
moveButton.className = "mjr-ps-btn";
moveButton.style.padding = "4px 10px";
moveButton.style.borderRadius = "6px";
moveButton.style.border = "1px solid rgba(255, 165, 0, 0.5)";
moveButton.style.background = "rgba(255, 165, 0, 0.25)";
moveButton.style.color = "#ffcc80";
moveButton.style.cursor = "pointer";
moveButton.style.fontSize = "11px";
moveButton.dataset.sourceKind = wrongFolderMatch.kind;
moveButton.dataset.sourceRelpath = wrongFolderMatch.relpath;
moveButton.dataset.targetKind = wrongFolderMatch.expected_kind;
moveContainer.appendChild(moveInfo);
moveContainer.appendChild(moveButton);
row.appendChild(label);
row.appendChild(missingLine);
row.appendChild(moveContainer);
}
else {
row.appendChild(label);
row.appendChild(missingLine);
}
const select = document.createElement("select");
select.className = "mjr-ps-select";
select.style.width = "100%";
select.style.padding = "6px 8px";
select.style.borderRadius = "8px";
select.style.border = "1px solid rgba(255,255,255,0.12)";
select.style.color = "#f2f4f8";
const emptyOpt = document.createElement("option");
emptyOpt.value = "";
emptyOpt.textContent = candidates.length ? "-- Select match --" : "No candidates found";
select.appendChild(emptyOpt);
for (const candidate of candidates) {
const opt = document.createElement("option");
opt.value = String(candidate?.relpath || "");
opt.textContent = formatCandidateLabel(candidate);
opt.title = String(candidate?.relpath || "");
select.appendChild(opt);
}
if (exactUnique) {
select.value = String(exactMatches[0]?.relpath || "");
}
if (!candidates.length) {
select.disabled = true;
select.style.opacity = "0.6";
}
row.appendChild(select);
list.appendChild(row);
rows.push({
node_id: entry?.node_id,
widget_name: entry?.widget_name,
select,
moveButton,
});
}
const actions = document.createElement("div");
actions.style.display = "flex";
actions.style.gap = "8px";
actions.style.justifyContent = "flex-end";
actions.style.marginTop = "6px";
const cancelBtn = document.createElement("button");
cancelBtn.textContent = "Cancel";
cancelBtn.className = "mjr-ps-btn";
cancelBtn.style.padding = "6px 10px";
cancelBtn.style.borderRadius = "8px";
cancelBtn.style.border = "1px solid rgba(255,255,255,0.2)";
cancelBtn.style.background = "rgba(255,255,255,0.06)";
cancelBtn.style.color = "#eee";
cancelBtn.style.cursor = "pointer";
const applyBtn = document.createElement("button");
applyBtn.textContent = "Apply Fix";
applyBtn.className = "mjr-ps-btn";
applyBtn.style.padding = "6px 10px";
applyBtn.style.borderRadius = "8px";
applyBtn.style.border = "1px solid rgba(46, 136, 255, 0.65)";
applyBtn.style.background = "rgba(46, 136, 255, 0.25)";
applyBtn.style.color = "#eee";
applyBtn.style.cursor = "pointer";
actions.appendChild(cancelBtn);
actions.appendChild(applyBtn);
panel.appendChild(actions);
const close = (value) => {
document.removeEventListener("keydown", onKeyDown);
if (overlay.parentNode)
overlay.remove();
resolve(value);
};
const onKeyDown = (event) => {
if (String(event.key || "").toLowerCase() === "escape") {
event.preventDefault();
close(null);
}
};
// Add event listeners to all move buttons
for (const row of rows) {
if (row.moveButton) {
row.moveButton.addEventListener("click", async () => {
const button = row.moveButton;
const sourceKind = button.dataset.sourceKind;
const sourceRelpath = button.dataset.sourceRelpath;
const targetKind = button.dataset.targetKind;
button.disabled = true;
button.textContent = "Moving...";
try {
const response = await fetch("/mjr_models/move_to_correct_folder", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
source_kind: sourceKind,
source_relpath: sourceRelpath,
target_kind: targetKind,
}),
});
const data = await response.json();
if (data.ok) {
button.textContent = "Moved!";
button.style.background = "rgba(76, 175, 80, 0.25)";
button.style.border = "1px solid rgba(76, 175, 80, 0.5)";
button.style.color = "#a5d6a7";
// Auto-select the newly moved file in the dropdown
if (row.select && data.target_relpath) {
row.select.value = data.target_relpath;
}
// Refresh the folder to show the new file
setTimeout(() => {
if (typeof app?.refreshComboInNodes === "function") {
app.refreshComboInNodes();
}
}, 500);
}
else {
throw new Error(data.error || "Move failed");
}
}
catch (error) {
console.error("[mjr] Failed to move model:", error);
button.textContent = "Error";
button.style.background = "rgba(244, 67, 54, 0.25)";
button.style.border = "1px solid rgba(244, 67, 54, 0.5)";
button.disabled = false;
setTimeout(() => {
button.textContent = "Move";
button.style.background = "rgba(255, 165, 0, 0.25)";
button.style.border = "1px solid rgba(255, 165, 0, 0.5)";
}, 3000);
}
});
}
}
applyBtn.addEventListener("click", () => {
const fixes = [];
for (const row of rows) {
const value = row.select.value;
if (!value)
continue;
fixes.push({
node_id: row.node_id,
widget_name: row.widget_name,
new_value: value,
});
}
close(fixes);
});
}
}
applyBtn.addEventListener("click", () => {
const fixes = [];
for (const row of rows) {
const value = row.select.value;
if (!value) continue;
fixes.push({
node_id: row.node_id,
widget_name: row.widget_name,
new_value: value,
cancelBtn.addEventListener("click", () => close(null));
overlay.addEventListener("click", (event) => {
if (event.target === overlay) {
close(null);
}
});
}
close(fixes);
document.addEventListener("keydown", onKeyDown);
document.body.appendChild(overlay);
});
cancelBtn.addEventListener("click", () => close(null));
overlay.addEventListener("click", (event) => {
if (event.target === overlay) {
close(null);
}
});
document.addEventListener("keydown", onKeyDown);
document.body.appendChild(overlay);
});
}
//# sourceMappingURL=ui_model_fixer_dialog.js.map
File diff suppressed because one or more lines are too long
+8
View File
@@ -0,0 +1,8 @@
export declare function titlePathJS(text: any): string;
export declare function token3Tag(raw: any, upper: any): string;
export declare function yymmddJS(): string;
export declare function safeRel(rel: any): string;
export declare function joinRel(a: any, b: any): string;
export declare function mediaDir(media: any): "02_OUT/VIDEOS" | "02_OUT/IMAGES" | "02_OUT/OTHER";
export declare function makeKindToken(kind: any): "ASSET" | "SHOT" | "MISC";
export declare function resolveTemplatePreview(template: any, tokens: any): any;
+110 -113
View File
@@ -1,136 +1,133 @@
export function titlePathJS(text) {
if (text == null) return "";
let t = String(text).trim();
if (t.normalize) {
t = t.normalize("NFKD").replace(/[^\x00-\x7F]/g, "");
}
t = t.replace(/[\\/]/g, " ");
const tokens = t.split(/[\s_-]+/).filter(Boolean);
const titled = tokens.map((tok) => {
if (tok.length <= 1) return tok.toUpperCase();
return tok[0].toUpperCase() + tok.slice(1).toLowerCase();
});
return titled.join("_") || "Project";
if (text == null)
return "";
let t = String(text).trim();
if (t.normalize) {
t = t.normalize("NFKD").replace(/[^\x00-\x7F]/g, "");
}
t = t.replace(/[\\/]/g, " ");
const tokens = t.split(/[\s_-]+/).filter(Boolean);
const titled = tokens.map((tok) => {
if (tok.length <= 1)
return tok.toUpperCase();
return tok[0].toUpperCase() + tok.slice(1).toLowerCase();
});
return titled.join("_") || "Project";
}
export function token3Tag(raw, upper) {
if (raw == null) return "";
let t = String(raw).trim();
if (!t) return "";
if (t.normalize) {
t = t.normalize("NFKD").replace(/[^\x00-\x7F]/g, "");
}
t = t.replace(/\\/g, "/").split("/").pop();
const lastDot = t.lastIndexOf(".");
if (lastDot > 0) {
t = t.slice(0, lastDot);
}
const tokens = t.split(/[\s_-]+/).filter(Boolean).slice(0, 3);
if (!tokens.length) return "";
if (upper) {
return tokens.join("_").toUpperCase();
}
return tokens
.map((tok) =>
tok.length <= 1 ? tok.toUpperCase() : tok[0].toUpperCase() + tok.slice(1).toLowerCase()
)
.join("_");
if (raw == null)
return "";
let t = String(raw).trim();
if (!t)
return "";
if (t.normalize) {
t = t.normalize("NFKD").replace(/[^\x00-\x7F]/g, "");
}
t = t.replace(/\\/g, "/").split("/").pop();
const lastDot = t.lastIndexOf(".");
if (lastDot > 0) {
t = t.slice(0, lastDot);
}
const tokens = t.split(/[\s_-]+/).filter(Boolean).slice(0, 3);
if (!tokens.length)
return "";
if (upper) {
return tokens.join("_").toUpperCase();
}
return tokens
.map((tok) => tok.length <= 1 ? tok.toUpperCase() : tok[0].toUpperCase() + tok.slice(1).toLowerCase())
.join("_");
}
export function yymmddJS() {
const d = new Date();
const yy = String(d.getFullYear()).slice(-2);
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${yy}${mm}${dd}`;
const d = new Date();
const yy = String(d.getFullYear()).slice(-2);
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${yy}${mm}${dd}`;
}
export function safeRel(rel) {
if (rel == null) return "";
let r = String(rel).replace(/\\/g, "/").trim();
r = r.replace(/^output\//i, "");
r = r.replace(/^\/+/, "");
r = r
.split("/")
.filter((p) => p && p !== "." && p !== "..")
.join("/");
return r;
if (rel == null)
return "";
let r = String(rel).replace(/\\/g, "/").trim();
r = r.replace(/^output\//i, "");
r = r.replace(/^\/+/, "");
r = r
.split("/")
.filter((p) => p && p !== "." && p !== "..")
.join("/");
return r;
}
export function joinRel(a, b) {
const left = safeRel(a);
const right = safeRel(b);
if (!left) return right;
if (!right) return left;
return `${left.replace(/\/+$/, "")}/${right.replace(/^\/+/, "")}`;
const left = safeRel(a);
const right = safeRel(b);
if (!left)
return right;
if (!right)
return left;
return `${left.replace(/\/+$/, "")}/${right.replace(/^\/+/, "")}`;
}
export function mediaDir(media) {
const m = String(media || "").toLowerCase();
if (m === "videos") return "02_OUT/VIDEOS";
if (m === "images") return "02_OUT/IMAGES";
return "02_OUT/OTHER";
const m = String(media || "").toLowerCase();
if (m === "videos")
return "02_OUT/VIDEOS";
if (m === "images")
return "02_OUT/IMAGES";
return "02_OUT/OTHER";
}
export function makeKindToken(kind) {
const k = String(kind || "").toLowerCase();
if (k === "asset") return "ASSET";
if (k === "shot") return "SHOT";
return "MISC";
const k = String(kind || "").toLowerCase();
if (k === "asset")
return "ASSET";
if (k === "shot")
return "SHOT";
return "MISC";
}
/**
* Validate that a path is relative and safe.
*/
function validateRelativePath(path, context) {
if (path.startsWith("/") || path.startsWith("\\")) {
throw new Error(`${context} must be relative`);
}
if (path.includes(":")) {
throw new Error(`${context} must be relative`);
}
if (path.includes("..")) {
throw new Error(`${context} contains '..'`);
}
if (path.startsWith("/") || path.startsWith("\\")) {
throw new Error(`${context} must be relative`);
}
if (path.includes(":")) {
throw new Error(`${context} must be relative`);
}
if (path.includes("..")) {
throw new Error(`${context} contains '..'`);
}
}
export function resolveTemplatePreview(template, tokens) {
if (!template) return "";
let out = template.replace(/\\/g, "/").trim();
if (!out) return "";
// Validate template before processing
validateRelativePath(out, "template");
if (!out.includes("{BASE}")) {
throw new Error("template must include {BASE}");
}
// Replace tokens (use split/join to handle multiple occurrences safely)
for (const [key, value] of Object.entries(tokens)) {
const safeValue = String(value || "");
// Prevent token values from containing other token patterns
if (safeValue.includes("{") || safeValue.includes("}")) {
throw new Error(`token value for {${key}} contains template markers`);
if (!template)
return "";
let out = template.replace(/\\/g, "/").trim();
if (!out)
return "";
// Validate template before processing
validateRelativePath(out, "template");
if (!out.includes("{BASE}")) {
throw new Error("template must include {BASE}");
}
out = out.split(`{${key}}`).join(safeValue);
}
// Normalize slashes
out = out.replace(/\\/g, "/").replace(/\/+/g, "/");
// Validate resolved path
validateRelativePath(out, "resolved path");
const parts = out.split("/").filter(Boolean);
// Verify base constraint
const base = String(tokens?.BASE || "").replace(/\\/g, "/").replace(/\/+$/, "");
if (base) {
if (!(out === base || out.startsWith(`${base}/`))) {
throw new Error("template must stay under {BASE}");
// Replace tokens (use split/join to handle multiple occurrences safely)
for (const [key, value] of Object.entries(tokens)) {
const safeValue = String(value || "");
// Prevent token values from containing other token patterns
if (safeValue.includes("{") || safeValue.includes("}")) {
throw new Error(`token value for {${key}} contains template markers`);
}
out = out.split(`{${key}}`).join(safeValue);
}
}
return parts.join("/");
// Normalize slashes
out = out.replace(/\\/g, "/").replace(/\/+/g, "/");
// Validate resolved path
validateRelativePath(out, "resolved path");
const parts = out.split("/").filter(Boolean);
// Verify base constraint
const base = String(tokens?.BASE || "").replace(/\\/g, "/").replace(/\/+$/, "");
if (base) {
if (!(out === base || out.startsWith(`${base}/`))) {
throw new Error("template must stay under {BASE}");
}
}
return parts.join("/");
}
//# sourceMappingURL=utils.js.map
File diff suppressed because one or more lines are too long
+3
View File
@@ -0,0 +1,3 @@
export declare function extractNoteTexts(workflowJson: any): unknown[];
export declare function parseRecipesFromNoteText(text: any): Map<any, any>;
export declare function collectRecipesFromWorkflowNotes(workflowJson: any): Map<any, any>;
+178 -180
View File
@@ -2,212 +2,210 @@ const NOTE_TYPE_RE = /markdown|note/i;
const NOTE_TITLE_RE = /note/i;
const ALLOWED_EXTENSIONS = [".safetensors", ".ckpt", ".pt", ".pth", ".bin"];
const SECTION_KINDS = new Set([
"text_encoders",
"clip_vision",
"loras",
"diffusion_models",
"vae",
"text_encoders",
"clip_vision",
"loras",
"diffusion_models",
"vae",
]);
function normalizeSectionLine(line) {
let t = String(line || "").trim().toLowerCase();
if (!t) return "";
t = t.replace(/^#+\s*/, "");
t = t.replace(/:\s*$/, "");
return t.trim();
let t = String(line || "").trim().toLowerCase();
if (!t)
return "";
t = t.replace(/^#+\s*/, "");
t = t.replace(/:\s*$/, "");
return t.trim();
}
function normalizeUrl(url) {
let u = String(url || "").trim();
if (!u) return "";
u = u.replace(/\/blob\//i, "/resolve/");
return u;
let u = String(url || "").trim();
if (!u)
return "";
u = u.replace(/\/blob\//i, "/resolve/");
return u;
}
function basenameFromUrl(url) {
try {
const parsed = new URL(url);
const path = parsed.pathname || "";
const parts = path.split("/").filter(Boolean);
const base = parts.length ? parts[parts.length - 1] : "";
return base;
} catch (_) {
const clean = String(url || "").split("?")[0].split("#")[0];
const parts = clean.split("/").filter(Boolean);
return parts.length ? parts[parts.length - 1] : "";
}
try {
const parsed = new URL(url);
const path = parsed.pathname || "";
const parts = path.split("/").filter(Boolean);
const base = parts.length ? parts[parts.length - 1] : "";
return base;
}
catch (_) {
const clean = String(url || "").split("?")[0].split("#")[0];
const parts = clean.split("/").filter(Boolean);
return parts.length ? parts[parts.length - 1] : "";
}
}
function hasAllowedExtension(filename) {
const lower = String(filename || "").toLowerCase();
return ALLOWED_EXTENSIONS.some((ext) => lower.endsWith(ext));
const lower = String(filename || "").toLowerCase();
return ALLOWED_EXTENSIONS.some((ext) => lower.endsWith(ext));
}
function inferKindFromUrl(url) {
const lower = String(url || "").toLowerCase();
// Check URL path for kind indicators
if (lower.includes("/text_encoders/") || lower.includes("text_encoder")) return "text_encoders";
if (lower.includes("/diffusion_models/") || lower.includes("diffusion_model")) return "diffusion_models";
if (lower.includes("/clip_vision/")) return "clip_vision";
if (lower.includes("/loras/") || lower.includes("/lora/")) return "loras";
if (lower.includes("/vae/")) return "vae";
if (lower.includes("/controlnet/")) return "controlnet";
if (lower.includes("/upscale_models/") || lower.includes("/upscale/")) return "upscale_models";
if (lower.includes("/clip/")) return "clip";
if (lower.includes("/unet/")) return "unet";
if (lower.includes("/embeddings/") || lower.includes("/embedding/")) return "embeddings";
if (lower.includes("/checkpoints/") || lower.includes("/checkpoint/")) return "checkpoints";
return "unknown";
const lower = String(url || "").toLowerCase();
// Check URL path for kind indicators
if (lower.includes("/text_encoders/") || lower.includes("text_encoder"))
return "text_encoders";
if (lower.includes("/diffusion_models/") || lower.includes("diffusion_model"))
return "diffusion_models";
if (lower.includes("/clip_vision/"))
return "clip_vision";
if (lower.includes("/loras/") || lower.includes("/lora/"))
return "loras";
if (lower.includes("/vae/"))
return "vae";
if (lower.includes("/controlnet/"))
return "controlnet";
if (lower.includes("/upscale_models/") || lower.includes("/upscale/"))
return "upscale_models";
if (lower.includes("/clip/"))
return "clip";
if (lower.includes("/unet/"))
return "unet";
if (lower.includes("/embeddings/") || lower.includes("/embedding/"))
return "embeddings";
if (lower.includes("/checkpoints/") || lower.includes("/checkpoint/"))
return "checkpoints";
return "unknown";
}
function getNoteTextFromNode(node) {
const props = node?.properties || {};
const candidates = [
props?.text,
props?.value,
props?.markdown,
];
for (const candidate of candidates) {
if (typeof candidate === "string" && candidate.trim().length > 20) {
return candidate;
const props = node?.properties || {};
const candidates = [
props?.text,
props?.value,
props?.markdown,
];
for (const candidate of candidates) {
if (typeof candidate === "string" && candidate.trim().length > 20) {
return candidate;
}
}
}
const widgetsValues = node?.widgets_values;
if (Array.isArray(widgetsValues)) {
const parts = widgetsValues.filter((v) => typeof v === "string" && v.trim().length > 0);
const joined = parts.join("\n");
if (joined.trim().length > 20) return joined;
}
const widgets = node?.widgets;
if (Array.isArray(widgets)) {
const parts = widgets
.map((w) => w?.value)
.filter((v) => typeof v === "string" && v.trim().length > 0);
const joined = parts.join("\n");
if (joined.trim().length > 20) return joined;
}
return "";
const widgetsValues = node?.widgets_values;
if (Array.isArray(widgetsValues)) {
const parts = widgetsValues.filter((v) => typeof v === "string" && v.trim().length > 0);
const joined = parts.join("\n");
if (joined.trim().length > 20)
return joined;
}
const widgets = node?.widgets;
if (Array.isArray(widgets)) {
const parts = widgets
.map((w) => w?.value)
.filter((v) => typeof v === "string" && v.trim().length > 0);
const joined = parts.join("\n");
if (joined.trim().length > 20)
return joined;
}
return "";
}
function isNoteLikeNode(node) {
const type = String(node?.type || node?.class_type || "").trim();
const title = String(node?.title || "").trim();
return NOTE_TYPE_RE.test(type) || NOTE_TITLE_RE.test(title);
const type = String(node?.type || node?.class_type || "").trim();
const title = String(node?.title || "").trim();
return NOTE_TYPE_RE.test(type) || NOTE_TITLE_RE.test(title);
}
function collectGraphsFromNode(node) {
return [
node?.subgraph,
node?._subgraph,
node?.subgraph?.graph,
node?.subgraph?.lgraph,
node?.properties?.subgraph,
node?.subgraph_instance,
node?.subgraph_instance?.graph,
].filter(Boolean);
return [
node?.subgraph,
node?._subgraph,
node?.subgraph?.graph,
node?.subgraph?.lgraph,
node?.properties?.subgraph,
node?.subgraph_instance,
node?.subgraph_instance?.graph,
].filter(Boolean);
}
function walkGraph(graph, visitor, visited) {
if (!graph || !Array.isArray(graph.nodes)) return;
if (visited.has(graph)) return;
visited.add(graph);
for (const node of graph.nodes) {
visitor(node);
const children = collectGraphsFromNode(node);
for (const child of children) {
if (Array.isArray(child?.nodes)) {
walkGraph(child, visitor, visited);
}
if (!graph || !Array.isArray(graph.nodes))
return;
if (visited.has(graph))
return;
visited.add(graph);
for (const node of graph.nodes) {
visitor(node);
const children = collectGraphsFromNode(node);
for (const child of children) {
if (Array.isArray(child?.nodes)) {
walkGraph(child, visitor, visited);
}
}
}
}
}
export function extractNoteTexts(workflowJson) {
if (!workflowJson || !Array.isArray(workflowJson.nodes)) return [];
const texts = new Set();
const visited = new WeakSet();
const graph = { nodes: workflowJson.nodes };
walkGraph(
graph,
(node) => {
if (!isNoteLikeNode(node)) return;
const text = getNoteTextFromNode(node);
if (text && text.trim().length > 20) {
texts.add(text.trim());
}
},
visited
);
return Array.from(texts);
if (!workflowJson || !Array.isArray(workflowJson.nodes))
return [];
const texts = new Set();
const visited = new WeakSet();
const graph = { nodes: workflowJson.nodes };
walkGraph(graph, (node) => {
if (!isNoteLikeNode(node))
return;
const text = getNoteTextFromNode(node);
if (text && text.trim().length > 20) {
texts.add(text.trim());
}
}, visited);
return Array.from(texts);
}
export function parseRecipesFromNoteText(text) {
const recipes = new Map();
const lines = String(text || "").split(/\r?\n/);
let currentKind = "unknown";
for (const line of lines) {
const normalized = normalizeSectionLine(line);
if (SECTION_KINDS.has(normalized)) {
currentKind = normalized;
continue;
const recipes = new Map();
const lines = String(text || "").split(/\r?\n/);
let currentKind = "unknown";
for (const line of lines) {
const normalized = normalizeSectionLine(line);
if (SECTION_KINDS.has(normalized)) {
currentKind = normalized;
continue;
}
const urls = new Set();
const mdRegex = /\[[^\]]+\]\((https?:\/\/[^\s)]+)\)/g;
let match = null;
while ((match = mdRegex.exec(line)) !== null) {
if (match[1])
urls.add(match[1]);
}
const rawRegex = /(https?:\/\/[^\s]+)/g;
while ((match = rawRegex.exec(line)) !== null) {
if (match[1])
urls.add(match[1]);
}
for (const url of urls) {
const normalizedUrl = normalizeUrl(url);
if (!normalizedUrl)
continue;
const filename = basenameFromUrl(normalizedUrl);
if (!filename || !hasAllowedExtension(filename))
continue;
if (recipes.has(filename)) {
console.debug("[mjr] workflow note recipe duplicate", filename);
continue;
}
// Use section kind if available, otherwise infer from URL
let finalKind = currentKind;
if (!finalKind || finalKind === "unknown") {
finalKind = inferKindFromUrl(normalizedUrl);
}
recipes.set(filename, {
key: filename,
kind: finalKind,
url: normalizedUrl,
filename,
source: "workflow_note",
});
}
}
const urls = new Set();
const mdRegex = /\[[^\]]+\]\((https?:\/\/[^\s)]+)\)/g;
let match = null;
while ((match = mdRegex.exec(line)) !== null) {
if (match[1]) urls.add(match[1]);
}
const rawRegex = /(https?:\/\/[^\s]+)/g;
while ((match = rawRegex.exec(line)) !== null) {
if (match[1]) urls.add(match[1]);
}
for (const url of urls) {
const normalizedUrl = normalizeUrl(url);
if (!normalizedUrl) continue;
const filename = basenameFromUrl(normalizedUrl);
if (!filename || !hasAllowedExtension(filename)) continue;
if (recipes.has(filename)) {
console.debug("[mjr] workflow note recipe duplicate", filename);
continue;
}
// Use section kind if available, otherwise infer from URL
let finalKind = currentKind;
if (!finalKind || finalKind === "unknown") {
finalKind = inferKindFromUrl(normalizedUrl);
}
recipes.set(filename, {
key: filename,
kind: finalKind,
url: normalizedUrl,
filename,
source: "workflow_note",
});
}
}
return recipes;
return recipes;
}
export function collectRecipesFromWorkflowNotes(workflowJson) {
const texts = extractNoteTexts(workflowJson);
const merged = new Map();
for (const text of texts) {
const parsed = parseRecipesFromNoteText(text);
for (const [key, recipe] of parsed.entries()) {
if (!merged.has(key)) {
merged.set(key, recipe);
}
const texts = extractNoteTexts(workflowJson);
const merged = new Map();
for (const text of texts) {
const parsed = parseRecipesFromNoteText(text);
for (const [key, recipe] of parsed.entries()) {
if (!merged.has(key)) {
merged.set(key, recipe);
}
}
}
}
return merged;
return merged;
}
//# sourceMappingURL=workflow_note_recipes.js.map
File diff suppressed because one or more lines are too long
+41
View File
@@ -0,0 +1,41 @@
export declare const DEFAULT_STATE: {
projectName: string;
workflowName: string;
workflowAsset: string;
workflowLastBase: string;
workflowNextSuffix: number;
workflowPanelOpen: boolean;
projectId: string;
projectFolder: string;
projectExists: any;
autoPatch: boolean;
autoPatchNewNodes: boolean;
autoSwitchTrusted: boolean;
autoHookSave: boolean;
kind: string;
media: string;
name: string;
modelSelection: string;
detectedModelTag: string;
useCustomModel: boolean;
customModelText: string;
modelUpper: boolean;
pendingProjectName: string;
pendingSelectProjectId: string;
lastRelDir: string;
lastPrefix: string;
pathTemplate: string;
showMoreModels: boolean;
lastError: string;
detected: any;
workflowHasSignature: boolean;
workflowChecked: boolean;
isGraphLoading: boolean;
graphLoadToken: number;
graphIsEmpty: boolean;
};
export declare let runtimeState: any;
export declare function setRuntimeState(state: any): void;
export declare function createRuntimeState(overrides: any): any;
export declare function loadState(): any;
export declare function saveState(state: any): void;
+78 -80
View File
@@ -1,92 +1,90 @@
const STORAGE_KEY = "mjr_project_settings_state";
export const DEFAULT_STATE = {
projectName: "",
workflowName: "",
workflowAsset: "",
workflowLastBase: "",
workflowNextSuffix: 1,
workflowPanelOpen: false,
projectId: "",
projectFolder: "",
projectExists: null,
autoPatch: true,
autoPatchNewNodes: true,
autoSwitchTrusted: false,
autoHookSave: true,
kind: "asset",
media: "images",
name: "",
modelSelection: "",
detectedModelTag: "",
useCustomModel: false,
customModelText: "",
modelUpper: false,
pendingProjectName: "",
pendingSelectProjectId: "",
lastRelDir: "",
lastPrefix: "",
pathTemplate: "{BASE}/{MEDIA}/{DATE}/{NAME}/{MODEL}",
showMoreModels: false,
lastError: "",
detected: null,
workflowHasSignature: false,
workflowChecked: false,
isGraphLoading: false,
graphLoadToken: 0,
graphIsEmpty: false,
projectName: "",
workflowName: "",
workflowAsset: "",
workflowLastBase: "",
workflowNextSuffix: 1,
workflowPanelOpen: false,
projectId: "",
projectFolder: "",
projectExists: null,
autoPatch: true,
autoPatchNewNodes: true,
autoSwitchTrusted: false,
autoHookSave: true,
kind: "asset",
media: "images",
name: "",
modelSelection: "",
detectedModelTag: "",
useCustomModel: false,
customModelText: "",
modelUpper: false,
pendingProjectName: "",
pendingSelectProjectId: "",
lastRelDir: "",
lastPrefix: "",
pathTemplate: "{BASE}/{MEDIA}/{DATE}/{NAME}/{MODEL}",
showMoreModels: false,
lastError: "",
detected: null,
workflowHasSignature: false,
workflowChecked: false,
isGraphLoading: false,
graphLoadToken: 0,
graphIsEmpty: false,
};
export let runtimeState = null;
export function setRuntimeState(state) {
runtimeState = state;
runtimeState = state;
}
export function createRuntimeState(overrides) {
return Object.assign({}, DEFAULT_STATE, overrides || {});
return Object.assign({}, DEFAULT_STATE, overrides || {});
}
export function loadState() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
return JSON.parse(raw);
} catch (_) {
return null;
}
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw)
return null;
return JSON.parse(raw);
}
catch (_) {
return null;
}
}
export function saveState(state) {
const data = {
projectName: state.projectName,
workflowName: state.workflowName,
workflowAsset: state.workflowAsset,
workflowLastBase: state.workflowLastBase,
workflowNextSuffix: state.workflowNextSuffix,
workflowPanelOpen: state.workflowPanelOpen,
projectId: state.projectId,
projectFolder: state.projectFolder,
autoPatch: state.autoPatch,
autoPatchNewNodes: state.autoPatchNewNodes,
autoSwitchTrusted: state.autoSwitchTrusted,
autoHookSave: state.autoHookSave,
kind: state.kind,
media: state.media,
name: state.name,
modelSelection: state.modelSelection,
detectedModelTag: state.detectedModelTag,
useCustomModel: state.useCustomModel,
customModelText: state.customModelText,
modelUpper: state.modelUpper,
pendingProjectName: state.pendingProjectName,
pendingSelectProjectId: state.pendingSelectProjectId,
lastRelDir: state.lastRelDir,
lastPrefix: state.lastPrefix,
pathTemplate: state.pathTemplate,
showMoreModels: state.showMoreModels,
};
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
} catch (_) {}
const data = {
projectName: state.projectName,
workflowName: state.workflowName,
workflowAsset: state.workflowAsset,
workflowLastBase: state.workflowLastBase,
workflowNextSuffix: state.workflowNextSuffix,
workflowPanelOpen: state.workflowPanelOpen,
projectId: state.projectId,
projectFolder: state.projectFolder,
autoPatch: state.autoPatch,
autoPatchNewNodes: state.autoPatchNewNodes,
autoSwitchTrusted: state.autoSwitchTrusted,
autoHookSave: state.autoHookSave,
kind: state.kind,
media: state.media,
name: state.name,
modelSelection: state.modelSelection,
detectedModelTag: state.detectedModelTag,
useCustomModel: state.useCustomModel,
customModelText: state.customModelText,
modelUpper: state.modelUpper,
pendingProjectName: state.pendingProjectName,
pendingSelectProjectId: state.pendingSelectProjectId,
lastRelDir: state.lastRelDir,
lastPrefix: state.lastPrefix,
pathTemplate: state.pathTemplate,
showMoreModels: state.showMoreModels,
};
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
}
catch (_) { }
}
//# sourceMappingURL=state_manager.js.map
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"state_manager.js","sourceRoot":"","sources":["../src/state_manager.ts"],"names":[],"mappings":"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"}
+1
View File
@@ -0,0 +1 @@
export declare function buildPanel(el: any, state: any, actions: any): void;
+2123 -2334
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+27
View File
@@ -0,0 +1,27 @@
{
"name": "comfyui-majoor-projectsettings",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "comfyui-majoor-projectsettings",
"devDependencies": {
"typescript": "^5.8.3"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
}
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "comfyui-majoor-projectsettings",
"private": true,
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"devDependencies": {
"typescript": "^5.8.3"
}
}
+858
View File
@@ -0,0 +1,858 @@
/**
* Majoor Project Settings (standalone)
* - Sidebar UI when available (fallback floating panel)
* - No nodes, no polling
*/
import { app } from "../../scripts/app.js";
import { joinRel, mediaDir, token3Tag, yymmddJS } from "./mjr/utils.js";
import { toast } from "./mjr/toast.js";
import { fetchJSON, saveWorkflow, tryResolveProjectId } from "./mjr/api.js";
import { psConfirm, psPrompt } from "./mjr/dialog.js";
import {
detectModelFromGraph,
getSerializedWorkflow,
inferProjectFolderFromGraph,
readGraphSignature,
} from "./mjr/graph.js";
import {
PATH_WIDGETS,
alreadyProjectPathed,
detectNodeMedia,
isSaveLikeNode,
loadConfig,
patchSaveNodes,
patchSingleNode,
stampGraphProjectSignature,
} from "./mjr/patch.js";
import { buildPanel } from "./ui_components.js";
import {
createRuntimeState,
loadState,
saveState,
runtimeState,
setRuntimeState,
} from "./state_manager.js";
import { scanMissingModelsWithStats } from "./mjr/model_fixer.js";
let workflowShortcutRegistered = false;
const NODE_PATCH_RETRY_DELAY_MS = 60;
const NODE_PATCH_MAX_RETRIES = 1;
const SETTINGS_CATEGORY = "MajoorPS";
const DOWNLOAD_MAX_GB_SETTING = "mjr_project.download_max_gb";
const DEFAULT_DOWNLOAD_MAX_GB = 50;
const AUTO_FIX_AFTER_DOWNLOAD_SETTING = "mjr_project.auto_fix_after_download";
const AUTO_OPEN_PANEL_ON_MISSING_SETTING = "mjr_project.auto_open_panel_on_missing";
const AUTO_SCAN_MISSING_ON_OPEN_SETTING = "mjr_project.auto_scan_missing_on_open";
const AUTO_REMEMBER_NOTE_SOURCES_SETTING = "mjr_project.auto_remember_note_sources";
const NO_PROJECT_STATUS_BLACK_SETTING = "mjr_project.status_black_no_project";
const DEFAULT_AUTO_FIX_AFTER_DOWNLOAD = true;
const DEFAULT_AUTO_OPEN_PANEL = true;
const DEFAULT_AUTO_SCAN_MISSING_ON_OPEN = true;
const DEFAULT_AUTO_REMEMBER_NOTE_SOURCES = true;
const DEFAULT_NO_PROJECT_STATUS_BLACK = false;
const GRAPH_EMPTY_POLL_INTERVAL_MS = 900;
let graphEmptyWatcherId = null;
function _extractYYMMDDFromPrefix(prefix) {
const m = String(prefix || "").match(/^(\d{6})_/);
return m ? m[1] : "";
}
function _extractYYMMDDFromRelDir(relDir) {
const m = String(relDir || "").replace(/\\/g, "/").match(/(?:^|\/)(\d{6})(?:\/|$)/);
return m ? m[1] : "";
}
function _replaceDateSegment(relDir, oldDate, newDate) {
if (!oldDate || !newDate || oldDate === newDate) return relDir;
const s = String(relDir || "").replace(/\\/g, "/");
const re = new RegExp(`(^|/)${oldDate}(/|$)`, "g");
return s.replace(re, `$1${newDate}$2`);
}
function _rollOutputDateIfNeeded(state) {
if (!state) return false;
const today = yymmddJS();
const oldDate = _extractYYMMDDFromPrefix(state.lastPrefix) || _extractYYMMDDFromRelDir(state.lastRelDir);
if (!oldDate || oldDate === today) return false;
state.lastRelDir = _replaceDateSegment(state.lastRelDir, oldDate, today);
if (String(state.lastPrefix || "").startsWith(`${oldDate}_`)) {
state.lastPrefix = `${today}${String(state.lastPrefix || "").slice(6)}`;
}
saveState(state);
updateUI(state);
return true;
}
const toNumber = (value, fallback) => {
if (value == null) return fallback;
const num = Number(value);
return Number.isFinite(num) ? num : fallback;
};
const toBool = (value, fallback) => {
if (value == null) return fallback;
if (typeof value === "boolean") return value;
if (typeof value === "number") return value !== 0;
if (typeof value === "string") {
const v = value.trim().toLowerCase();
if (["true", "1", "yes", "on"].includes(v)) return true;
if (["false", "0", "no", "off"].includes(v)) return false;
}
return fallback;
};
const getSettingBool = (id, fallback) => {
const raw =
app?.extensionManager?.setting?.get?.(id) ??
app?.ui?.settings?.getSettingValue?.(id) ??
app?.ui?.settings?.settingsValues?.[id];
return toBool(raw, fallback);
};
function updateUI(state) {
const safe = (fn, label) => {
try {
const result = fn?.();
if (result && typeof result.then === "function") {
result.catch((err) => {
console.error(`[mjr] UI update failed: ${label}`, err);
if (label === "status") {
toast("error", "UI Error", `Failed to update ${label}`, { life: 3000 });
}
});
}
} catch (err) {
console.error(`[mjr] UI update failed: ${label}`, err);
// Show critical errors to user
if (label === "status") {
toast("error", "UI Error", `Failed to update ${label}`, { life: 3000 });
}
}
};
safe(state?._ui?.updateStatus, "status");
safe(state?._ui?.updateResolve, "resolve");
safe(state?._ui?.updateWorkflowBlock, "workflow");
safe(state?._ui?.updateTargetLabel, "target label");
safe(state?._ui?.refreshExistingNames, "existing names");
safe(state?._ui?.refreshMissingStatus, "missing models");
safe(state?._ui?.updatePreview, "preview");
}
async function setActiveProjectById(state, projectId, token) {
const resp = await fetchJSON("/mjr_project/list");
const list = resp.projects || [];
const entry = list.find((p) => p.project_id === projectId);
if (!entry) {
throw new Error("project not found");
}
if (token != null && state?.graphLoadToken !== token) {
return null;
}
state.projectId = entry.project_id;
state.projectFolder = entry.folder || "";
state.projectExists = entry.exists !== false;
state.lastError = "";
_rollOutputDateIfNeeded(state);
saveState(state);
updateUI(state);
state?._ui?.resetForProjectChange?.();
// Auto-patch output nodes when project is opened
try {
const relDir = state.lastRelDir || "";
const prefix = state.lastPrefix || "";
if (relDir || prefix) {
const patched = await patchSaveNodes(app, relDir, prefix);
if (patched > 0) {
console.log(`[mjr] Auto-patched ${patched} save node(s) with project outputs`);
}
}
} catch (err) {
console.error("[mjr] Failed to auto-patch nodes on project open:", err);
}
toast("success", "Project active", entry.folder || entry.project_id);
return entry;
}
/**
* Shared function to create and activate a project.
* Eliminates code duplication across multiple create buttons.
*/
async function createAndActivateProject(state, projectName, updateCallbacks) {
const {
updateStatus,
updateResolve,
updateWorkflowBlock,
resetForProjectChange,
updatePreview,
refreshProjectsList,
} = updateCallbacks;
const resp = await fetchJSON("/mjr_project/set", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ project_name: projectName, create_base: true }),
});
state.projectName = projectName;
state.projectId = resp.project_id;
state.projectFolder = resp.project_folder;
state.projectExists = true;
state.lastError = "";
state.workflowHasSignature = true;
updateStatus();
updateResolve();
updateWorkflowBlock();
saveState(state);
resetForProjectChange();
await updatePreview();
refreshProjectsList(true);
stampGraphProjectSignature(app, state);
// Auto-patch output nodes when project is created
try {
const relDir = state.lastRelDir || "";
const prefix = state.lastPrefix || "";
if (relDir || prefix) {
const patched = await patchSaveNodes(app, relDir, prefix);
if (patched > 0) {
console.log(`[mjr] Auto-patched ${patched} save node(s) after project creation`);
}
}
} catch (err) {
console.error("[mjr] Failed to auto-patch nodes on project creation:", err);
}
toast("success", "Project created", resp.project_folder);
return resp;
}
const buildUpdateCallbacks = (state) => {
const ui = state?._ui || {};
return {
updateStatus: ui.updateStatus || (() => {}),
updateResolve: ui.updateResolve || (() => {}),
updateWorkflowBlock: ui.updateWorkflowBlock || (() => {}),
resetForProjectChange: ui.resetForProjectChange || (() => {}),
updatePreview: ui.updatePreview || (async () => {}),
refreshProjectsList: ui.refreshProjectsList || (() => {}),
};
};
async function promptEmptyGraphProject(state, runToken) {
if (!state || state.projectId) return;
if (state._emptyGraphPromptToken === runToken) return;
state._emptyGraphPromptToken = runToken;
const confirmCreate = await psConfirm({
title: "Empty workflow",
message: "This workflow is empty. Create a new project now?\nCancel to set an existing project.",
});
if (state.graphLoadToken !== runToken) return;
if (confirmCreate) {
const entered = await psPrompt({
title: "Project name",
message: "Enter a project name",
defaultValue: state.pendingProjectName || "",
});
if (state.graphLoadToken !== runToken) return;
if (entered === null) return;
const projectName = String(entered || "").trim();
if (!projectName) return;
await createAndActivateProject(state, projectName, buildUpdateCallbacks(state));
return;
}
state.workflowPanelOpen = true;
saveState(state);
updateUI(state);
state._ui?.refreshProjectsList?.(true);
}
const isGraphCurrentlyEmpty = () => {
const nodes = app?.graph?._nodes;
return !Array.isArray(nodes) || nodes.length === 0;
};
function handleGraphEmptyTransition(state, runToken, currentEmpty) {
if (!state) return false;
const wasEmpty = Boolean(state.graphIsEmpty);
if (wasEmpty === currentEmpty) {
if (!currentEmpty) {
state._emptyGraphPromptToken = null;
}
return false;
}
state.graphIsEmpty = currentEmpty;
if (!currentEmpty) {
state._emptyGraphPromptToken = null;
} else {
void promptEmptyGraphProject(state, runToken ?? state.graphLoadToken);
}
return true;
}
function startGraphEmptyWatcher(state) {
if (!state) return;
if (graphEmptyWatcherId) {
clearInterval(graphEmptyWatcherId);
graphEmptyWatcherId = null;
}
graphEmptyWatcherId = setInterval(() => {
if (!state) return;
const empty = isGraphCurrentlyEmpty();
if (handleGraphEmptyTransition(state, state.graphLoadToken, empty)) {
saveState(state);
updateUI(state);
}
}, GRAPH_EMPTY_POLL_INTERVAL_MS);
}
async function onGraphLoadedDetectProject(state, token) {
if (!state) return;
await loadConfig();
const runToken = typeof token === "number" ? token : state.graphLoadToken || 0;
const isStale = () => state.graphLoadToken !== runToken;
const graphNodes = app?.graph?._nodes;
const graphEmpty = !Array.isArray(graphNodes) || graphNodes.length === 0;
const guardToast = (type, title, message, opts) => {
if (!isStale() && !graphEmpty) {
toast(type, title, message, opts);
}
};
if (isStale()) return;
// Collect all state mutations in local variables
let workflowHasSignature = false;
const workflowChecked = true;
let pendingProjectName = state.pendingProjectName || "";
let pendingSelectProjectId = state.pendingSelectProjectId || "";
let detected = {
mode: "none",
project_folder: "",
project_id: "",
confidence: 0,
};
let detectedModelTag = state.detectedModelTag || "";
let lastError = "";
if (!graphEmpty) {
const sig = readGraphSignature();
if (sig && (sig.project_id || sig.project_folder)) {
detected.mode = "signature";
detected.project_id = String(sig.project_id || "");
detected.project_folder = String(sig.project_folder || "");
detected.confidence = 100;
workflowHasSignature = true;
guardToast(
"info",
"Project detected",
`Workflow signature: ${detected.project_folder || detected.project_id}`
);
} else {
const folder = inferProjectFolderFromGraph(isSaveLikeNode);
if (folder) {
detected.mode = "path";
detected.project_folder = folder;
detected.confidence = 60;
guardToast("info", "Project inferred", `From save paths: ${folder}`);
} else {
guardToast("warn", "No project detected", "This workflow has no PROJECTS paths or signature.");
}
guardToast("warn", "Workflow unassigned", "Assign or create a project for this workflow.");
}
}
if (!workflowHasSignature) {
if (detected.project_folder) {
pendingProjectName = detected.project_folder;
}
pendingSelectProjectId = "";
}
if (!detected.project_id && detected.project_folder) {
detected.project_id = await tryResolveProjectId(detected.project_folder);
if (isStale()) return;
}
const activeId = state.projectId || "";
const detectedId = detected.project_id || "";
const hasActive = !!activeId;
const conflict = hasActive && detectedId && activeId !== detectedId;
if (
detected.mode === "signature" &&
detectedId &&
state.autoSwitchTrusted &&
(!hasActive || conflict)
) {
if (isStale()) return;
try {
const entry = await setActiveProjectById(state, detectedId, runToken);
if (isStale() || !entry) return;
detected = { mode: "none", project_folder: "", project_id: "", confidence: 0 };
} catch (e) {
if (isStale()) return;
lastError = String(e.message || e);
guardToast("error", "Auto-switch failed", lastError);
}
} else if (conflict) {
guardToast(
"warn",
"Project mismatch",
`Workflow: ${detected.project_folder || detected.project_id}`
);
}
if (!graphEmpty) {
const modelTag = detectModelFromGraph(token3Tag) || "";
if (modelTag !== state.detectedModelTag) {
detectedModelTag = modelTag;
if (modelTag) {
guardToast("info", "Model detected", modelTag);
}
}
}
// Final staleness check before committing state mutations
if (isStale()) return;
// Atomic state update - all mutations happen together
state.workflowHasSignature = workflowHasSignature;
state.workflowChecked = workflowChecked;
state.pendingProjectName = pendingProjectName;
state.pendingSelectProjectId = pendingSelectProjectId;
state.detected = detected;
state.workflowPanelOpen = graphEmpty ? false : !workflowHasSignature;
handleGraphEmptyTransition(state, runToken, graphEmpty);
state.detectedModelTag = detectedModelTag;
if (lastError) {
state.lastError = lastError;
}
saveState(state);
updateUI(state);
}
async function saveWorkflowToProject(state, nameOverride) {
if (!state?.projectId) {
toast("error", "No active project", "Set a project before saving workflows.");
return;
}
// Get workflow BEFORE stamping signature to avoid partial state
const workflow = getSerializedWorkflow();
if (!workflow) {
toast("error", "Workflow not available", "Could not serialize the current graph.");
return;
}
let workflowName = (nameOverride ?? state.workflowName ?? "").trim();
if (!workflowName) {
const suggestion = state?._ui?.buildWorkflowName?.() ?? "";
const entered = await psPrompt({
title: "Workflow name",
message: "Enter a workflow name base",
defaultValue: suggestion,
});
if (entered === null) {
toast("info", "Workflow save cancelled", "No name provided.");
return;
}
workflowName = String(entered || "").trim();
if (!workflowName) {
toast("error", "Validation", "Workflow name is required.");
return;
}
state?._ui?.setWorkflowName?.(workflowName);
}
const assetFolder = (state.workflowAsset || "").trim();
try {
// Stamp signature before save attempt
stampGraphProjectSignature(app, state);
const resp = await saveWorkflow({
project_id: state.projectId,
workflow_name: workflowName,
workflow,
overwrite: false,
mirror_to_comfy_workflows: true,
use_project_subfolder_in_workflows: true,
asset_folder: assetFolder,
});
// Only update state after successful save
state.workflowHasSignature = true;
state.workflowChecked = true;
state.lastError = "";
const fileLabel = resp.file || "workflow.json";
const projectInfo = resp.project_rel_path || "PROJECTS/<PROJECT>/03_WORKFLOWS";
toast("success", "Workflow saved", `${fileLabel} -> ${projectInfo}`);
const fileBase = String(resp.file || "").replace(/\.json$/i, "");
const suffixMatch = fileBase.match(/_(\d{4})$/);
if (suffixMatch) {
const base = fileBase.slice(0, -5);
state.workflowLastBase = base;
state.workflowNextSuffix = Number(suffixMatch[1]) + 1;
state?._ui?.setWorkflowName?.(base);
}
saveState(state);
updateUI(state);
if (!resp.mirrored) {
const reason = resp.mirror_error || "Mirror disabled or unavailable.";
toast("warn", "Workflow mirror", reason);
}
} catch (e) {
state.lastError = String(e.message || e);
// Note: Signature was stamped, but save failed
// User can try again with stamped signature
updateUI(state);
toast("error", "Workflow save failed", state.lastError);
}
}
function registerWorkflowShortcut() {
if (workflowShortcutRegistered) return;
workflowShortcutRegistered = true;
window.addEventListener(
"keydown",
(event) => {
const key = String(event.key || "").toLowerCase();
if (!event.ctrlKey && !event.metaKey) return;
if (key !== "s") return;
const state = runtimeState;
if (!state || state.autoHookSave === false) return;
if (!state.projectId) return;
if (event.cancelable) event.preventDefault();
event.stopPropagation();
event.stopImmediatePropagation();
saveWorkflowToProject(state);
},
true
);
}
const EXTENSION_SETTINGS = [
{
id: DOWNLOAD_MAX_GB_SETTING,
name: "MajoorPS: Download max size (GB)",
type: "number",
defaultValue: DEFAULT_DOWNLOAD_MAX_GB,
min: 1,
max: 1024,
step: 1,
category: SETTINGS_CATEGORY,
},
{
id: AUTO_FIX_AFTER_DOWNLOAD_SETTING,
name: "MajoorPS: Auto-run Fix after download",
type: "boolean",
defaultValue: DEFAULT_AUTO_FIX_AFTER_DOWNLOAD,
category: SETTINGS_CATEGORY,
},
{
id: AUTO_OPEN_PANEL_ON_MISSING_SETTING,
name: "MajoorPS: Auto-open panel on missing models",
type: "boolean",
defaultValue: DEFAULT_AUTO_OPEN_PANEL,
category: SETTINGS_CATEGORY,
},
{
id: AUTO_SCAN_MISSING_ON_OPEN_SETTING,
name: "MajoorPS: Auto-scan missing on panel open",
type: "boolean",
defaultValue: DEFAULT_AUTO_SCAN_MISSING_ON_OPEN,
category: SETTINGS_CATEGORY,
},
{
id: AUTO_REMEMBER_NOTE_SOURCES_SETTING,
name: "MajoorPS: Auto-remember note sources",
type: "boolean",
defaultValue: DEFAULT_AUTO_REMEMBER_NOTE_SOURCES,
category: SETTINGS_CATEGORY,
},
{
id: NO_PROJECT_STATUS_BLACK_SETTING,
name: "MajoorPS: Black status when no project",
type: "boolean",
defaultValue: DEFAULT_NO_PROJECT_STATUS_BLACK,
category: SETTINGS_CATEGORY,
},
];
function registerLegacyExtensionSettings() {
if (app?.extensionManager?.setting) return;
const settings = app?.ui?.settings;
if (!settings?.addSetting) return;
try {
const values = settings.settingsValues || {};
for (const setting of EXTENSION_SETTINGS) {
const fallback =
setting.type === "number"
? toNumber(values[setting.id], setting.defaultValue)
: toBool(values[setting.id], setting.defaultValue);
settings.addSetting({ ...setting, defaultValue: fallback });
}
} catch (e) {
console.warn("[mjr] settings registration failed", e);
}
}
app.registerExtension({
name: "Majoor.ProjectSettings",
settings: EXTENSION_SETTINGS,
async setup() {
await loadConfig();
registerLegacyExtensionSettings();
const canSidebar = !!app?.extensionManager?.registerSidebarTab;
const state = createRuntimeState(loadState() || {});
if (!state.kind) state.kind = "asset";
if (state.media === "asset") {
state.kind = "asset";
state.media = "images";
}
if (state.media === "shot") {
state.kind = "shot";
state.media = "videos";
}
if (state.customModel && !state.customModelText) {
state.customModelText = state.customModel;
}
if (state.modelSelection === "__custom__") {
state.modelSelection = "";
}
if (state.pathTemplate === "{BASE}/{MEDIA}/{DATE}/{MODEL}/{NAME}") {
state.pathTemplate = "{BASE}/{MEDIA}/{DATE}/{NAME}/{MODEL}";
}
if (state.useCustomModel == null) {
state.useCustomModel = false;
}
if (state.autoHookSave == null) {
state.autoHookSave = true;
}
if (!state.workflowLastBase) {
state.workflowLastBase = "";
}
const nextSuffix = Number(state.workflowNextSuffix);
if (!nextSuffix || nextSuffix < 1) {
state.workflowNextSuffix = 1;
} else {
state.workflowNextSuffix = Math.floor(nextSuffix);
}
if (state.modelUpper == null) {
state.modelUpper = state.useCustomModel === true;
}
if (!state.detectedModelTag) {
state.detectedModelTag = "";
}
if (!Number.isFinite(state.graphLoadToken)) {
state.graphLoadToken = 0;
}
setRuntimeState(state);
_rollOutputDateIfNeeded(state);
registerWorkflowShortcut();
state._emptyGraphPromptToken = null;
startGraphEmptyWatcher(state);
const renderPanel = (el) =>
buildPanel(el, state, {
createAndActivateProject,
setActiveProjectById,
saveWorkflowToProject,
});
if (canSidebar) {
app.extensionManager.registerSidebarTab({
id: "mjr_project_settings",
icon: "pi pi-briefcase",
title: "Project",
tooltip: "Majoor Project Settings",
type: "custom",
render: (el) => renderPanel(el),
});
} else {
const box = document.createElement("div");
box.style.position = "fixed";
box.style.top = "64px";
box.style.right = "16px";
box.style.zIndex = "9999";
box.style.background = "rgba(20,20,20,0.9)";
box.style.border = "1px solid rgba(255,255,255,0.1)";
box.style.padding = "10px";
box.style.borderRadius = "10px";
box.style.width = "280px";
document.body.appendChild(box);
renderPanel(box);
}
},
async beforeConfigureGraph() {
if (runtimeState) {
runtimeState.isGraphLoading = true;
runtimeState.graphLoadToken = (runtimeState.graphLoadToken || 0) + 1;
}
},
async afterConfigureGraph() {
if (runtimeState) {
runtimeState.isGraphLoading = false;
onGraphLoadedDetectProject(runtimeState, runtimeState.graphLoadToken);
_rollOutputDateIfNeeded(runtimeState);
// Automatically scan for missing models when workflow loads (deferred to avoid blocking graph configure)
if (getSettingBool(AUTO_SCAN_MISSING_ON_OPEN_SETTING, DEFAULT_AUTO_SCAN_MISSING_ON_OPEN)) {
const scanToken = runtimeState.graphLoadToken;
setTimeout(() => {
if (!runtimeState || runtimeState.graphLoadToken !== scanToken) return;
try {
const { missing } = scanMissingModelsWithStats();
if (runtimeState?._ui?.refreshMissingStatus) {
runtimeState._ui.refreshMissingStatus();
}
if (missing && missing.length > 0) {
toast(
"warn",
"Missing Models",
`${missing.length} missing model${missing.length !== 1 ? "s" : ""} detected in workflow`,
{ life: 4000 }
);
if (getSettingBool(AUTO_OPEN_PANEL_ON_MISSING_SETTING, DEFAULT_AUTO_OPEN_PANEL)) {
const sidebar = app?.extensionManager?.sidebar;
if (sidebar && typeof sidebar.setTab === "function") {
setTimeout(() => {
sidebar.setTab("mjr_project_settings");
}, 100);
}
}
}
} catch (error) {
console.error("[mjr] Error scanning missing models on workflow load:", error);
}
}, 0);
}
}
},
async beforeQueuePrompt() {
if (runtimeState) {
// Roll date if needed before execution
_rollOutputDateIfNeeded(runtimeState);
// Auto-patch output nodes with current date before execution
if (runtimeState.projectId && (runtimeState.lastRelDir || runtimeState.lastPrefix)) {
try {
const relDir = runtimeState.lastRelDir || "";
const prefix = runtimeState.lastPrefix || "";
const patched = await patchSaveNodes(app, relDir, prefix);
if (patched > 0) {
console.log(`[mjr] Auto-patched ${patched} save node(s) before execution`);
}
} catch (err) {
console.error("[mjr] Failed to auto-patch nodes before execution:", err);
}
}
stampGraphProjectSignature(app, runtimeState);
}
},
async nodeCreated(node) {
const state = runtimeState;
if (!state || state.isGraphLoading) return;
if (state.graphIsEmpty) {
if (handleGraphEmptyTransition(state, state.graphLoadToken, false)) {
saveState(state);
updateUI(state);
}
}
await loadConfig();
if (!state.projectId || !state.autoPatchNewNodes) return;
if (!state.projectFolder || state.projectExists === false) return;
if (!isSaveLikeNode(node)) return;
let warnedPathed = false;
let timeoutId = null;
const attemptPatch = (retriesLeft) => {
// Check if node was removed from graph
const nodeStillExists = app.graph?._nodes?.includes(node);
if (!nodeStillExists) {
// Clean up timeout if node was removed
if (timeoutId !== null) {
clearTimeout(timeoutId);
timeoutId = null;
}
return;
}
if (!state.projectId || !state.autoPatchNewNodes) return;
if (!state.projectFolder || state.projectExists === false) return;
if (state.isGraphLoading) return;
const widgets = node?.widgets || [];
const filenameWidget = widgets.find((w) => w?.name === "filename_prefix");
const pathWidget = widgets.find((w) => w?.name && PATH_WIDGETS.includes(w.name));
if (!filenameWidget && !pathWidget) {
if (retriesLeft > 0) {
timeoutId = setTimeout(() => attemptPatch(retriesLeft - 1), NODE_PATCH_RETRY_DELAY_MS);
}
return;
}
if (filenameWidget && alreadyProjectPathed(filenameWidget.value)) {
if (!warnedPathed) {
warnedPathed = true;
toast("warn", "Patch skipped", "Node already points to a project path.");
}
return;
}
if (pathWidget && alreadyProjectPathed(pathWidget.value)) {
if (!warnedPathed) {
warnedPathed = true;
toast("warn", "Patch skipped", "Node already points to a project path.");
}
return;
}
const nm = detectNodeMedia(node) || "images";
_rollOutputDateIfNeeded(state);
let relDir = state.lastRelDir;
let prefix = state.lastPrefix || "";
if (!relDir) {
const base = `PROJECTS/${state.projectFolder}`;
relDir = joinRel(base, mediaDir(nm));
}
if (patchSingleNode(node, relDir, prefix)) {
try {
app.graph.setDirtyCanvas(true, true);
} catch (_) {}
toast("success", "Auto-patch", "New save node patched.", { life: 2000 });
} else if (retriesLeft > 0) {
timeoutId = setTimeout(() => attemptPatch(retriesLeft - 1), NODE_PATCH_RETRY_DELAY_MS);
}
};
attemptPatch(NODE_PATCH_MAX_RETRIES);
},
});
+216
View File
@@ -0,0 +1,216 @@
let _csrfTokenPromise = null;
function _getApiKey() {
try {
const key = localStorage.getItem("mjr_api_key");
return (key || "").trim();
} catch (_) {
return "";
}
}
async function _getCsrfToken(forceRefresh = false) {
if (forceRefresh) {
_csrfTokenPromise = null;
}
if (_csrfTokenPromise) return _csrfTokenPromise;
_csrfTokenPromise = (async () => {
try {
const resp = await fetch("/mjr_security/csrf", {
credentials: "same-origin",
headers: {
"Accept": "application/json"
}
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok || data?.ok === false) {
console.error("[MJR] Failed to get CSRF token:", data?.error || `HTTP ${resp.status}`);
throw new Error(data?.error || `HTTP ${resp.status}`);
}
const token = data?.csrf_token || "";
if (!token) {
console.error("[MJR] CSRF token is empty");
}
return token;
} catch (err) {
console.error("[MJR] Error fetching CSRF token:", err);
_csrfTokenPromise = null; // Reset on error
throw err;
}
})();
return _csrfTokenPromise;
}
export async function fetchJSON(url, opts) {
const o = opts ? { ...opts } : {};
o.credentials = o.credentials || "same-origin";
o.headers = { ...(o.headers || {}) };
const apiKey = _getApiKey();
if (apiKey && !o.headers["X-MJR-API-Key"] && !o.headers["Authorization"]) {
o.headers["X-MJR-API-Key"] = apiKey;
}
const method = String(o.method || "GET").toUpperCase();
if (["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
const csrf = await _getCsrfToken();
if (csrf) {
o.headers["X-CSRF-Token"] = csrf;
} else {
console.warn("[MJR] No CSRF token available for request");
}
}
const resp = await fetch(url, o);
// If we get a 403 (Forbidden), it might be a CSRF issue - retry once with fresh token
if (resp.status === 403 && ["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
console.warn("[MJR] Got 403, retrying with fresh CSRF token...");
const freshCsrf = await _getCsrfToken(true);
if (freshCsrf && freshCsrf !== o.headers["X-CSRF-Token"]) {
o.headers["X-CSRF-Token"] = freshCsrf;
const retryResp = await fetch(url, o);
const retryData = await retryResp.json().catch(() => ({}));
if (!retryResp.ok || retryData?.ok === false) {
throw new Error(retryData?.error || `HTTP ${retryResp.status}`);
}
return retryData;
}
}
const data = await resp.json().catch(() => ({}));
if (!resp.ok || data?.ok === false) {
throw new Error(data?.error || `HTTP ${resp.status}`);
}
return data;
}
export async function tryResolveProjectId(folder) {
try {
const data = await fetchJSON(`/mjr_project/resolve?folder=${encodeURIComponent(folder)}`);
if (data?.ok) return data.project_id || "";
} catch (_) {}
return "";
}
export async function listProjects() {
return fetchJSON("/mjr_project/list");
}
export async function setProject(project_name, create_base = true) {
return fetchJSON("/mjr_project/set", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ project_name, create_base }),
});
}
export async function getModels() {
return fetchJSON("/mjr_project/models");
}
export async function createCustomOut(payload) {
return fetchJSON("/mjr_project/create_custom_out", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload || {}),
});
}
export async function previewTemplate(template, tokens) {
return fetchJSON("/mjr_project/preview_template", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ template, tokens: tokens || {} }),
});
}
export async function listExistingNames(project_id, media = "images") {
const qs = new URLSearchParams({
project_id: project_id || "",
media: media || "images",
});
const data = await fetchJSON(`/mjr_project/assets/list_names?${qs.toString()}`);
return Array.isArray(data?.names) ? data.names : [];
}
export async function saveWorkflow(payload) {
return fetchJSON("/mjr_project/workflow/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload || {}),
});
}
export async function scanModelCandidates(missing) {
return fetchJSON("/mjr_models/scan_candidates", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ missing: missing || [] }),
});
}
export async function resolveModelRecipes(missing) {
return fetchJSON("/mjr_models/resolve_recipes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ missing: missing || [] }),
});
}
export async function saveModelRecipes(items) {
return fetchJSON("/mjr_models/save_recipes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: items || [] }),
});
}
export async function downloadModels(items) {
return fetchJSON("/mjr_models/download", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: items || [] }),
});
}
export async function getDownloadStatus(job_id) {
const qs = new URLSearchParams({ job_id: job_id || "" });
return fetchJSON(`/mjr_models/download_status?${qs.toString()}`);
}
export async function getFingerprintCacheStatus() {
return fetchJSON("/mjr_models/fingerprint_cache_status");
}
export async function buildFingerprintCache(payload) {
return fetchJSON("/mjr_models/build_fingerprint_cache", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload || {}),
});
}
export async function resolveFingerprint(payload) {
return fetchJSON("/mjr_models/resolve_by_fingerprint", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload || {}),
});
}
export async function searchRegistry(query, limit = 6) {
return fetchJSON("/mjr_models/registry/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: String(query || ""), limit }),
});
}
export async function contributeRegistry(payload) {
return fetchJSON("/mjr_models/registry/contribute", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload || {}),
});
}
+42
View File
@@ -0,0 +1,42 @@
import { app } from "../../../scripts/app.js";
export async function psPrompt({ title, message, defaultValue } = {}) {
const dialog = app?.extensionManager?.dialog;
if (dialog?.prompt) {
try {
const result = await dialog.prompt({
title: title || "Input",
message: message || "",
defaultValue: defaultValue ?? "",
default: defaultValue ?? "",
});
if (result === null || result === undefined) return null;
if (typeof result === "object" && "value" in result) {
const value = result.value;
return value == null ? null : String(value);
}
return String(result);
} catch (_) {}
}
const fallback = window.prompt(message || title || "", defaultValue ?? "");
if (fallback === null) return null;
return String(fallback);
}
export async function psConfirm({ title, message } = {}) {
const dialog = app?.extensionManager?.dialog;
if (dialog?.confirm) {
try {
const result = await dialog.confirm({
title: title || "Confirm",
message: message || "",
});
if (typeof result === "boolean") return result;
if (typeof result === "object" && "value" in result) {
return Boolean(result.value);
}
return Boolean(result);
} catch (_) {}
}
return window.confirm(message || title || "Confirm?");
}
+129
View File
@@ -0,0 +1,129 @@
import { app } from "../../../scripts/app.js";
const DEBUG = false;
function getInnerGraphFromNode(node) {
if (!node) return null;
const candidates = [
node.subgraph,
node._subgraph,
node?.subgraph?.graph,
node?.subgraph?.lgraph,
node?.properties?.subgraph,
node.subgraph_instance,
node?.subgraph_instance?.graph,
];
for (const candidate of candidates) {
if (!candidate) continue;
if (Array.isArray(candidate?._nodes)) return candidate;
if (Array.isArray(candidate?.graph?._nodes)) return candidate.graph;
}
return null;
}
function scanGraphForModel(graph, token3TagFn, visited) {
if (!graph || !Array.isArray(graph._nodes)) return "";
if (visited.has(graph)) return "";
visited.add(graph);
const keys = [
"ckpt",
"checkpoint",
"checkpoint_name",
"ckpt_name",
"model",
"model_name",
"diffusion",
"diffusion_model",
"unet",
"unet_name",
"gguf",
];
for (const node of graph._nodes) {
const widgets = node?.widgets || [];
for (const w of widgets) {
const name = String(w?.name || "").toLowerCase();
if (!name) continue;
if (!keys.some((k) => name.includes(k))) continue;
const val = w?.value;
if (typeof val !== "string") continue;
const tag = token3TagFn ? token3TagFn(val, false) : "";
if (tag) {
if (DEBUG) console.debug("[mjr] model tag found", tag);
return tag;
}
}
const inner = getInnerGraphFromNode(node);
if (inner) {
if (DEBUG) console.debug("[mjr] scanning subgraph for model");
const subTag = scanGraphForModel(inner, token3TagFn, visited);
if (subTag) return subTag;
}
}
return "";
}
function scanGraphForProjectFolder(graph, isSaveLikeNodeFn, visited) {
if (!graph || !Array.isArray(graph._nodes)) return "";
if (visited.has(graph)) return "";
visited.add(graph);
for (const node of graph._nodes) {
if (isSaveLikeNodeFn && !isSaveLikeNodeFn(node)) continue;
const widgets = node?.widgets || [];
for (const w of widgets) {
const v = w?.value;
if (typeof v === "string" || typeof v === "number") {
const s = String(v).replace(/\\/g, "/");
const m = s.match(/PROJECTS\/([^/]+)/i);
if (m && m[1]) return m[1];
}
}
const inner = getInnerGraphFromNode(node);
if (inner) {
if (DEBUG) console.debug("[mjr] scanning subgraph for project folder");
const subFolder = scanGraphForProjectFolder(inner, isSaveLikeNodeFn, visited);
if (subFolder) return subFolder;
}
}
return "";
}
export function readGraphSignature() {
const g = app?.graph;
if (!g) return null;
const direct = g?.extra?.mjr_project;
if (direct && typeof direct === "object") return direct;
try {
const ser = g?.serialize?.();
const sig = ser?.extra?.mjr_project;
if (sig && typeof sig === "object") return sig;
} catch (_) {}
return null;
}
export function inferProjectFolderFromGraph(isSaveLikeNodeFn) {
const g = app?.graph;
return scanGraphForProjectFolder(g, isSaveLikeNodeFn, new WeakSet());
}
export function detectModelFromGraph(token3TagFn) {
const g = app?.graph;
return scanGraphForModel(g, token3TagFn, new WeakSet());
}
export function getSerializedWorkflow() {
const g = app?.graph;
if (!g || typeof g.serialize !== "function") return null;
try {
const ser = g.serialize();
if (g.extra && typeof g.extra === "object") {
ser.extra = ser.extra || {};
Object.assign(ser.extra, g.extra);
}
return ser;
} catch (_) {
return null;
}
}
+20
View File
@@ -0,0 +1,20 @@
// Simple logging wrapper for Majoor Project Settings UI
export const MJR_DEBUG = (typeof window !== 'undefined' && Boolean(window?.MJR_DEBUG)) || false;
export function debug(...args) {
if (MJR_DEBUG) {
console.debug(...args);
}
}
export function info(...args) {
console.info(...args);
}
export function warn(...args) {
console.warn(...args);
}
export function error(...args) {
console.error(...args);
}
+335
View File
@@ -0,0 +1,335 @@
import { ensureStyles, toast } from "./toast.js";
import { contributeRegistry } from "./api.js";
const PLATFORM_OPTIONS = [
{ value: "huggingface", label: "HuggingFace" },
{ value: "civitai", label: "CivitAI" },
{ value: "github", label: "GitHub" },
{ value: "modelscope", label: "ModelScope" },
{ value: "other", label: "Other" },
];
const TYPE_OPTIONS = [
{ value: "checkpoints", label: "Checkpoint" },
{ value: "loras", label: "LoRA" },
{ value: "vae", label: "VAE" },
{ value: "controlnet", label: "ControlNet" },
{ value: "embeddings", label: "Embedding" },
];
class ModelContributionDialog {
constructor() {
this.dialog = null;
this.statusEl = null;
this.submitBtn = null;
this.validateBtn = null;
this.inputs = {};
}
show(modelName = "") {
this.close();
ensureStyles();
this._render(modelName);
}
close() {
if (this.dialog) {
this.dialog.remove();
this.dialog = null;
this.statusEl = null;
this.submitBtn = null;
this.validateBtn = null;
this.inputs = {};
}
}
_render(modelName) {
this.dialog = document.createElement("div");
this.dialog.className = "mjr-contribution-dialog";
Object.assign(this.dialog.style, {
position: "fixed",
left: "0",
top: "0",
width: "100%",
height: "100%",
zIndex: "9999",
pointerEvents: "none",
});
const overlay = document.createElement("div");
Object.assign(overlay.style, {
position: "absolute",
inset: "0",
background: "rgba(0,0,0,0.65)",
backdropFilter: "blur(4px)",
});
const panel = document.createElement("div");
Object.assign(panel.style, {
position: "absolute",
left: "50%",
top: "50%",
transform: "translate(-50%, -50%)",
background: "rgba(15, 18, 26, 0.95)",
border: "1px solid rgba(255,255,255,0.2)",
borderRadius: "16px",
padding: "24px",
width: "360px",
color: "#f2f4f8",
fontFamily: "inherit",
pointerEvents: "auto",
boxShadow: "0 20px 50px rgba(0,0,0,0.4)",
display: "flex",
flexDirection: "column",
gap: "12px",
});
panel.innerHTML = `
<h2 style="margin:0;font-size:18px;letter-spacing:0.5px;text-transform:uppercase;">Share a model URL</h2>
<p style="margin:0;font-size:12px;color:#a0b3d6;">Help others by contributing a verified download link.</p>
`;
const fields = [
{ id: "name", label: "Model name", placeholder: "Stable Diffusion 1.5" },
{ id: "url", label: "Download URL", placeholder: "https://huggingface.co/.../model.safetensors" },
];
for (const field of fields) {
const wrap = document.createElement("div");
wrap.style.display = "flex";
wrap.style.flexDirection = "column";
wrap.style.gap = "4px";
const label = document.createElement("label");
label.textContent = field.label;
label.style.fontSize = "11px";
label.style.opacity = "0.8";
const input = document.createElement("input");
input.type = "text";
input.placeholder = field.placeholder;
input.style.padding = "8px 10px";
input.style.borderRadius = "8px";
input.style.border = "1px solid rgba(255,255,255,0.15)";
input.style.background = "rgba(255,255,255,0.04)";
input.style.color = "#f4f6fb";
input.style.fontSize = "13px";
if (field.id === "name" && modelName) {
input.value = modelName;
}
wrap.appendChild(label);
wrap.appendChild(input);
panel.appendChild(wrap);
this.inputs[field.id] = input;
}
const selects = [
{ id: "platform", label: "Platform", options: PLATFORM_OPTIONS },
{ id: "type", label: "Model type", options: TYPE_OPTIONS },
];
for (const selectCfg of selects) {
const wrap = document.createElement("div");
wrap.style.display = "flex";
wrap.style.flexDirection = "column";
wrap.style.gap = "4px";
const label = document.createElement("label");
label.textContent = selectCfg.label;
label.style.fontSize = "11px";
label.style.opacity = "0.8";
const select = document.createElement("select");
select.style.padding = "8px 10px";
select.style.borderRadius = "8px";
select.style.border = "1px solid rgba(255,255,255,0.15)";
select.style.background = "rgba(255,255,255,0.04)";
select.style.color = "#f4f6fb";
select.style.fontSize = "13px";
for (const option of selectCfg.options) {
const opt = document.createElement("option");
opt.value = option.value;
opt.textContent = option.label;
select.appendChild(opt);
}
wrap.appendChild(label);
wrap.appendChild(select);
panel.appendChild(wrap);
this.inputs[selectCfg.id] = select;
}
const extraWrap = document.createElement("div");
extraWrap.style.display = "flex";
extraWrap.style.flexDirection = "column";
extraWrap.style.gap = "4px";
const fileLabel = document.createElement("label");
fileLabel.textContent = "Filename (optional)";
fileLabel.style.fontSize = "11px";
fileLabel.style.opacity = "0.8";
const fileInput = document.createElement("input");
fileInput.type = "text";
fileInput.placeholder = "model.safetensors";
Object.assign(fileInput.style, {
padding: "8px 10px",
borderRadius: "8px",
border: "1px solid rgba(255,255,255,0.15)",
background: "rgba(255,255,255,0.04)",
color: "#f4f6fb",
fontSize: "13px",
});
const shaLabel = document.createElement("label");
shaLabel.textContent = "SHA256 (optional)";
shaLabel.style.fontSize = "11px";
shaLabel.style.opacity = "0.8";
const shaInput = document.createElement("input");
shaInput.type = "text";
shaInput.placeholder = "abc123...";
Object.assign(shaInput.style, {
padding: "8px 10px",
borderRadius: "8px",
border: "1px solid rgba(255,255,255,0.15)",
background: "rgba(255,255,255,0.04)",
color: "#f4f6fb",
fontSize: "13px",
});
extraWrap.appendChild(fileLabel);
extraWrap.appendChild(fileInput);
extraWrap.appendChild(shaLabel);
extraWrap.appendChild(shaInput);
panel.appendChild(extraWrap);
this.inputs.filename = fileInput;
this.inputs.sha256 = shaInput;
this.statusEl = document.createElement("div");
this.statusEl.style.minHeight = "20px";
this.statusEl.style.fontSize = "12px";
this.statusEl.style.opacity = "0.85";
panel.appendChild(this.statusEl);
const actions = document.createElement("div");
actions.style.display = "flex";
actions.style.gap = "8px";
actions.style.justifyContent = "space-between";
this.validateBtn = document.createElement("button");
this.validateBtn.textContent = "Validate URL";
this.validateBtn.className = "mjr-ps-btn";
this.validateBtn.style.flex = "1";
this.validateBtn.type = "button";
this.submitBtn = document.createElement("button");
this.submitBtn.textContent = "Contribute";
this.submitBtn.className = "mjr-ps-btn";
this.submitBtn.style.flex = "1";
this.submitBtn.type = "button";
actions.appendChild(this.validateBtn);
actions.appendChild(this.submitBtn);
panel.appendChild(actions);
const closeBtn = document.createElement("button");
closeBtn.textContent = "Cancel";
closeBtn.type = "button";
closeBtn.style.alignSelf = "flex-end";
closeBtn.style.fontSize = "12px";
closeBtn.style.background = "transparent";
closeBtn.style.border = "none";
closeBtn.style.color = "#9ab1d8";
panel.appendChild(closeBtn);
this.dialog.appendChild(overlay);
this.dialog.appendChild(panel);
document.body.appendChild(this.dialog);
this.validateBtn.addEventListener("click", () => this._validateUrl());
this.submitBtn.addEventListener("click", () => this._submit());
closeBtn.addEventListener("click", () => this.close());
overlay.addEventListener("click", () => this.close());
}
_setStatus(message, tone = "info") {
if (!this.statusEl) return;
this.statusEl.textContent = message;
const colors = {
info: "#a0b3d6",
success: "#84ff9c",
error: "#f87c7c",
};
this.statusEl.style.color = colors[tone] || colors.info;
}
_validateUrl() {
const value = (this.inputs.url?.value || "").trim();
if (!value) {
this._setStatus("Enter the download URL first.", "error");
return;
}
if (!/^https?:\/\//i.test(value)) {
this._setStatus("URL must start with http:// or https://", "error");
return;
}
this._setStatus("URL format looks good. You can submit!", "success");
}
async _submit() {
if (!this.submitBtn || !this.dialog) return;
const name = (this.inputs.name?.value || "").trim();
const url = (this.inputs.url?.value || "").trim();
if (!name || !url) {
this._setStatus("Model name and URL are required.", "error");
return;
}
if (!/^https?:\/\//i.test(url)) {
this._setStatus("URL must start with http(s)", "error");
return;
}
this.submitBtn.disabled = true;
this.validateBtn.disabled = true;
this._setStatus("Sending contribution...", "info");
try {
const payload = {
name,
url,
platform: this.inputs.platform?.value || "unknown",
type: this.inputs.type?.value || "checkpoints",
filename: (this.inputs.filename?.value || "").trim(),
sha256: (this.inputs.sha256?.value || "").trim(),
};
const resp = await contributeRegistry(payload);
const added = resp?.added || false;
this._setStatus(added ? "Contribution recorded, thanks!" : "Source already exists.", "success");
toast("success", "Contribution", added ? "Thank you for sharing!" : "Source already in registry.");
if (added) {
setTimeout(() => this.close(), 1600);
}
} catch (error) {
console.error("Contribution failed", error);
this._setStatus("Failed to send contribution.", "error");
toast("error", "Contribution failed", String(error?.message || error));
} finally {
this.submitBtn.disabled = false;
this.validateBtn.disabled = false;
}
}
}
let _dialogInstance = null;
export function showModelContributionDialog(modelName = "") {
if (!_dialogInstance) {
_dialogInstance = new ModelContributionDialog();
}
_dialogInstance.show(modelName);
}
+81
View File
@@ -0,0 +1,81 @@
const KIND_OPTIONS = [
{ value: "checkpoints", label: "Checkpoint" },
{ value: "diffusion_models", label: "Diffusion Model" },
{ value: "loras", label: "LoRA" },
{ value: "vae", label: "VAE" },
{ value: "controlnet", label: "ControlNet" },
{ value: "text_encoders", label: "Text Encoder" },
{ value: "clip", label: "CLIP" },
{ value: "clip_vision", label: "CLIP Vision" },
{ value: "unet", label: "UNet" },
{ value: "upscale_models", label: "Upscale Model" },
{ value: "embeddings", label: "Embedding" },
];
const TYPE_HINT_KIND = {
checkpoint: "checkpoints",
diffusion: "diffusion_models",
diffusion_models: "diffusion_models",
lora: "loras",
vae: "vae",
controlnet: "controlnet",
upscale_models: "upscale_models",
text_encoder: "text_encoders",
text_encoders: "text_encoders",
clip: "clip",
clip_vision: "clip_vision",
unet: "unet",
embeddings: "embeddings",
unknown: "",
};
import { collectRecipesFromWorkflowNotes } from "./workflow_note_recipes.js";
const ALLOWED_EXTENSIONS = [".safetensors", ".ckpt", ".pt", ".pth", ".bin"];
export function getKindOptions() {
return KIND_OPTIONS.slice();
}
export function typeHintToKind(typeHint) {
const key = String(typeHint || "").toLowerCase();
return TYPE_HINT_KIND[key] || "";
}
export function getAllowedExtensions() {
return ALLOWED_EXTENSIONS.slice();
}
export function isValidUrl(url) {
try {
const parsed = new URL(String(url || ""));
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch (_) {
return false;
}
}
export function extractFilenameFromUrl(url) {
try {
const parsed = new URL(String(url || ""));
const parts = parsed.pathname.split("/").filter(Boolean);
return parts.length ? parts[parts.length - 1] : "";
} catch (_) {
return "";
}
}
export function hasAllowedExtension(filename) {
const name = String(filename || "").toLowerCase();
return ALLOWED_EXTENSIONS.some((ext) => name.endsWith(ext));
}
export function normalizeKey(value) {
const text = String(value || "").replace(/\\/g, "/");
const base = text.split("/").pop() || "";
return base.split("?")[0].split("#")[0];
}
export function collectNoteRecipes(workflowJson) {
return collectRecipesFromWorkflowNotes(workflowJson);
}
+331
View File
@@ -0,0 +1,331 @@
import { app } from "../../../scripts/app.js";
const MAX_MISSING = 200;
const VALUE_SET_THRESHOLD = 256;
// Widgets to exclude from model detection (images, etc.)
const IMAGE_WIDGET_NAMES = new Set([
"image",
"images",
"input_image",
"image_path",
"mask",
"background",
"foreground",
]);
const FILE_WIDGET_NAME_HINTS = [
"image",
"video",
"audio",
"mask",
"path",
"file",
"filename",
"directory",
"folder",
"output",
];
const FILE_EXTENSIONS = [
".png",
".jpg",
".jpeg",
".webp",
".gif",
".bmp",
".tiff",
".tif",
".mp4",
".webm",
".mov",
".mkv",
".avi",
".mp3",
".wav",
".flac",
".ogg",
];
function isImageWidget(widgetName) {
const lower = String(widgetName || "").toLowerCase();
if (IMAGE_WIDGET_NAMES.has(lower)) return true;
if (lower.includes("png") || lower.includes("jpg") || lower.includes("jpeg")) return true;
return false;
}
function looksLikeFileWidget(widgetName, value) {
const name = String(widgetName || "").toLowerCase();
if (FILE_WIDGET_NAME_HINTS.some((h) => name.includes(h))) return true;
const v = String(value || "").toLowerCase().trim();
return FILE_EXTENSIONS.some((ext) => v.endsWith(ext));
}
function typeHintFromNode(node, widgetName) {
const nodeType = String(node?.type || node?.title || "").toLowerCase();
const widgetLower = String(widgetName || "").toLowerCase();
const nodeHasClipVision =
nodeType.includes("clip_vision") ||
nodeType.includes("clip vision") ||
(nodeType.includes("clip") && nodeType.includes("vision"));
// Match based on node type (highest priority)
if (nodeHasClipVision) return "clip_vision";
if (nodeType.includes("checkpoint")) {
if (widgetLower.includes("vae")) return "vae";
if (widgetLower.includes("lora")) return "lora";
return "checkpoint";
}
if (nodeType.includes("loader")) {
if (widgetLower.includes("vae")) return "vae";
if (widgetLower.includes("lora")) return "lora";
if (widgetLower.includes("unet") || widgetLower.includes("diffusion")) return "unet";
if (widgetLower.includes("text_encoder") || widgetLower.includes("text encoder"))
return "text_encoders";
if (widgetLower.includes("clip")) return "clip";
}
if (nodeType.includes("lora")) return "lora";
if (nodeType.includes("vae")) return "vae";
if (nodeType.includes("controlnet") || nodeType.includes("control")) return "controlnet";
if (nodeType.includes("upscale")) return "upscale_models";
if (nodeType.includes("clip")) {
if (widgetLower.includes("vision")) return "clip_vision";
return "clip";
}
if (nodeType.includes("text_encoder") || nodeType.includes("text encoder"))
return "text_encoders";
if (nodeType.includes("unet")) return "unet";
if (nodeType.includes("embedding") || nodeType.includes("textual")) return "embeddings";
// Match based on widget name (secondary priority)
if (widgetLower.includes("ckpt") || widgetLower.includes("checkpoint")) return "checkpoint";
if (widgetLower.includes("lora")) return "lora";
if (widgetLower.includes("vae")) return "vae";
if (widgetLower.includes("controlnet") || widgetLower.includes("control")) return "controlnet";
if (widgetLower.includes("upscale")) return "upscale_models";
if (widgetLower.includes("clip")) {
if (widgetLower.includes("vision")) return "clip_vision";
return "clip";
}
if (widgetLower.includes("text_encoder") || widgetLower.includes("text encoder"))
return "text_encoders";
if (widgetLower.includes("diffusion")) return "diffusion_models";
if (widgetLower.includes("unet")) return "unet";
if (widgetLower.includes("embedding")) return "embeddings";
return "unknown";
}
function isModelWidget(node, widgetName, value) {
if (isImageWidget(widgetName)) return false;
if (looksLikeFileWidget(widgetName, value)) return false;
return typeHintFromNode(node, widgetName) !== "unknown";
}
// Recursively collect all nodes including those in subgraphs and groups
function collectAllNodes(graph) {
const allNodes = [];
const visited = new WeakSet();
function collectFromGraph(g) {
if (!g || !Array.isArray(g._nodes)) return;
for (const node of g._nodes) {
if (!node || typeof node !== "object") continue;
if (visited.has(node)) continue;
visited.add(node);
allNodes.push(node);
// Check for subgraph
if (node.subgraph && typeof node.subgraph === "object") {
collectFromGraph(node.subgraph);
}
// Check for group nodes (some implementations)
if (node.graph && typeof node.graph === "object" && node.graph !== g) {
collectFromGraph(node.graph);
}
}
}
collectFromGraph(graph);
return allNodes;
}
function _valuesContain(values, value, widget) {
if (!Array.isArray(values) || values.length === 0) return false;
const valueStr = String(value);
if (values.length < VALUE_SET_THRESHOLD) {
for (const item of values) {
if (String(item) === valueStr) return true;
}
return false;
}
if (!widget || typeof widget !== "object") {
const set = new Set(values.map((v) => String(v)));
return set.has(valueStr);
}
if (widget.__mjrValuesRef !== values || !(widget.__mjrValuesSet instanceof Set)) {
widget.__mjrValuesRef = values;
widget.__mjrValuesSet = new Set(values.map((v) => String(v)));
}
return widget.__mjrValuesSet.has(valueStr);
}
export function scanMissingModelsFromGraph() {
return scanMissingModelsWithStats().missing;
}
export function scanMissingModelsWithStats() {
const graph = app?.graph;
if (!graph) return { missing: [], total: 0 };
// Collect all nodes including subgraphs and groups
const allNodes = collectAllNodes(graph);
const missing = [];
const nodesWithMissing = new Set();
let total = 0;
for (const node of allNodes) {
const widgets = node?.widgets || [];
for (const widget of widgets) {
const widgetName = widget?.name || "";
const values = widget?.options?.values;
if (!Array.isArray(values)) continue;
const value = widget?.value;
if (!value) continue;
// Only scan model selector widgets (skip image/video/file/path widgets, etc.)
if (!isModelWidget(node, widgetName, value)) continue;
total += 1;
if (_valuesContain(values, value, widget)) continue;
missing.push({
node_id: node?.id ?? null,
node_title: node?.title || node?.type || "",
widget_name: widgetName,
missing_value: String(value),
type_hint: typeHintFromNode(node, widgetName),
});
// Track nodes with missing models
if (node?.id != null) {
nodesWithMissing.add(node.id);
}
if (missing.length >= MAX_MISSING) break;
}
if (missing.length >= MAX_MISSING) break;
}
// Highlight nodes with missing models
highlightNodesWithMissingModels(allNodes, nodesWithMissing);
return { missing, total };
}
function highlightNodesWithMissingModels(allNodes, nodesWithMissing) {
for (const node of allNodes) {
if (!node) continue;
if (nodesWithMissing.has(node.id)) {
if (!node.__mjrMissingHighlight) {
node.__mjrMissingHighlight = true;
node.__mjrMissingColor = node.color ?? null;
node.__mjrMissingBgcolor = node.bgcolor ?? null;
}
// Highlight node with missing models (border only)
node.color = "#FF0000";
node.bgcolor = null;
} else if (node.__mjrMissingHighlight) {
// Restore original colors if no longer missing
node.color = node.__mjrMissingColor ?? null;
node.bgcolor = node.__mjrMissingBgcolor ?? null;
delete node.__mjrMissingHighlight;
delete node.__mjrMissingColor;
delete node.__mjrMissingBgcolor;
}
}
// Force canvas redraw
if (typeof app?.graph?.setDirtyCanvas === "function") {
app.graph.setDirtyCanvas(true, true);
}
}
export function applyFixesToGraph(fixes) {
const graph = app?.graph;
if (!graph) return 0;
// Collect all nodes including subgraphs and groups
const allNodes = collectAllNodes(graph);
const nodesById = new Map();
for (const node of allNodes) {
if (node && node.id != null) {
nodesById.set(node.id, node);
}
}
let fixed = 0;
const fixedNodeIds = new Set();
for (const fix of fixes || []) {
if (!fix) continue;
const node = nodesById.get(fix.node_id);
if (!node) continue;
const widgets = node?.widgets || [];
const widget = widgets.find((w) => w?.name === fix.widget_name);
if (!widget) continue;
widget.value = fix.new_value;
// Ensure widget callbacks fire so nodes update internal state.
if (typeof widget.callback === "function") {
try {
widget.callback(widget.value, app.graph, node, widget);
} catch (_) {}
}
if (typeof node.onWidgetChanged === "function") {
try {
node.onWidgetChanged(widget.name, widget.value, widget);
} catch (_) {}
}
if (typeof node.onPropertyChanged === "function") {
try {
node.onPropertyChanged(widget.name, widget.value, widget);
} catch (_) {}
}
if (Array.isArray(node.widgets)) {
node.widgets_values = node.widgets.map((w) => w?.value);
}
if (typeof node.setDirtyCanvas === "function") {
node.setDirtyCanvas(true, true);
}
fixed += 1;
fixedNodeIds.add(fix.node_id);
}
// Clear highlighting on fixed nodes
for (const nodeId of fixedNodeIds) {
const node = nodesById.get(nodeId);
if (node && node.color === "#FF0000") {
node.color = null;
node.bgcolor = null;
}
}
if (fixed > 0 && typeof graph.setDirtyCanvas === "function") {
graph.setDirtyCanvas(true, true);
}
if (fixed > 0 && typeof app?.refreshComboInNodes === "function") {
try {
app.refreshComboInNodes();
} catch (_) {}
}
return fixed;
}
+107
View File
@@ -0,0 +1,107 @@
import { fetchJSON } from "./api.js";
import { safeRel, joinRel } from "./utils.js";
export let PATH_WIDGETS = [];
let configPromise = null;
export async function loadConfig() {
if (configPromise) return configPromise;
configPromise = (async () => {
try {
const resp = await fetchJSON("/mjr_project/config");
const list = Array.isArray(resp?.path_widgets) ? resp.path_widgets : [];
PATH_WIDGETS = list.map((item) => String(item)).filter(Boolean);
return PATH_WIDGETS;
} catch (err) {
console.error("[mjr] Failed to load config:", err);
return PATH_WIDGETS;
} finally {
configPromise = null;
}
})();
return configPromise;
}
export function detectNodeMedia(node) {
const t = String(node?.comfyClass || node?.type || "").toLowerCase();
if (t.includes("video") || t.includes("mp4") || t.includes("gif")) return "videos";
if (t.includes("image") || t.includes("png") || t.includes("jpg") || t.includes("webp"))
return "images";
const widgets = node?.widgets || [];
const hasVideoHints = widgets.some((w) =>
["frame_rate", "fps", "duration", "frames_per_second"].includes(String(w?.name || ""))
);
return hasVideoHints ? "videos" : "images";
}
export function isSaveLikeNode(node) {
const t = String(node?.comfyClass || node?.type || "").toLowerCase();
if (!t.includes("save") && !t.includes("export") && !t.includes("combine")) return false;
const widgets = node?.widgets || [];
const hasFilename = widgets.some((w) => w?.name === "filename_prefix");
const hasPath = widgets.some((w) => w?.name && PATH_WIDGETS.includes(w.name));
return hasFilename || hasPath;
}
export function alreadyProjectPathed(value) {
if (!value) return false;
const s = String(value).replace(/\\/g, "/");
return s.includes("PROJECTS/") || s.includes("02_OUT/") || s.includes("00_META/");
}
export function patchSingleNode(node, relDir, filenamePrefix) {
if (!isSaveLikeNode(node)) return false;
const rel = safeRel(relDir);
const prefix = safeRel(filenamePrefix);
const widgets = node?.widgets || [];
const filenameWidget = widgets.find((w) => w?.name === "filename_prefix");
const pathWidget = widgets.find((w) => w?.name && PATH_WIDGETS.includes(w.name));
if (pathWidget && filenameWidget) {
pathWidget.value = rel;
filenameWidget.value = prefix;
return true;
}
if (filenameWidget) {
filenameWidget.value = prefix ? joinRel(rel, prefix) : rel;
return true;
}
if (pathWidget) {
pathWidget.value = rel;
return true;
}
return false;
}
export async function patchSaveNodes(app, relDir, filenamePrefix, targetMedia) {
await loadConfig();
const nodes = app?.graph?._nodes || [];
let patched = 0;
for (const node of nodes) {
const nm = detectNodeMedia(node);
if (targetMedia && nm && nm !== targetMedia) continue;
if (patchSingleNode(node, relDir, filenamePrefix)) {
patched += 1;
}
}
try {
app.graph.setDirtyCanvas(true, true);
} catch (_) {}
return patched;
}
export function stampGraphProjectSignature(app, state) {
if (!state?.projectId || !state?.projectFolder) return;
const g = app?.graph;
if (!g) return;
g.extra = g.extra || {};
g.extra.mjr_project = {
project_id: state.projectId,
project_folder: state.projectFolder,
template: state.pathTemplate || "",
updated_at: new Date().toISOString(),
};
}
+126
View File
@@ -0,0 +1,126 @@
import { app } from "../../../scripts/app.js";
export function ensureStyles() {
if (document.getElementById("mjr-project-settings-style")) return;
const style = document.createElement("style");
style.id = "mjr-project-settings-style";
style.textContent = `
.mjr-ps-btn {
display: inline-flex;
align-items: center;
justify-content: center;
will-change: transform;
transition: transform 80ms ease, background 160ms ease, border-color 160ms ease;
}
.mjr-ps-btn:hover {
background: rgba(46, 136, 255, 0.25);
border-color: rgba(46, 136, 255, 0.6);
transform: scale(1.03);
}
.mjr-ps-btn:active {
transform: scale(0.96);
}
.mjr-ps-btn:focus-visible {
outline: 2px solid rgba(46, 136, 255, 0.6);
outline-offset: 1px;
}
.mjr-ps-select {
background: rgba(12, 14, 18, 0.7);
color: #f2f4f8;
border: 1px solid rgba(255,255,255,0.12);
backdrop-filter: blur(6px);
color-scheme: dark;
appearance: none;
}
.mjr-ps-select option {
background: #121418 !important;
color: #f2f4f8 !important;
}
.mjr-ps-select optgroup {
background: #101218 !important;
color: #b6c0d3 !important;
}
.mjr-ps-select option:hover,
.mjr-ps-select option:checked {
background: #1a2230 !important;
color: #f2f4f8 !important;
}
`;
document.head.appendChild(style);
}
let toastContainer = null;
function getToastContainer() {
if (toastContainer && document.body.contains(toastContainer)) return toastContainer;
let el = document.getElementById("mjr-ps-toast-container");
if (!el) {
el = document.createElement("div");
el.id = "mjr-ps-toast-container";
el.style.position = "fixed";
el.style.top = "16px";
el.style.right = "16px";
el.style.zIndex = "10000";
el.style.display = "flex";
el.style.flexDirection = "column";
el.style.gap = "8px";
el.style.pointerEvents = "none";
document.body.appendChild(el);
}
toastContainer = el;
return el;
}
export function toast(type, title, message, opts = {}) {
const map = { success: "success", info: "info", warn: "warn", error: "error" };
const severity = map[type] || "info";
try {
const mgr = app?.extensionManager?.toast;
if (mgr?.add) {
mgr.add({
severity,
summary: title || "",
detail: message || "",
life: Number(opts.life || 4000),
});
return;
}
} catch (_) {}
try {
const container = getToastContainer();
const item = document.createElement("div");
const colors = {
success: "rgba(46, 143, 78, 0.9)",
info: "rgba(46, 136, 255, 0.9)",
warn: "rgba(199, 124, 42, 0.9)",
error: "rgba(179, 58, 58, 0.9)",
};
item.style.background = colors[severity] || colors.info;
item.style.color = "#f2f4f8";
item.style.padding = "8px 10px";
item.style.borderRadius = "8px";
item.style.boxShadow = "0 6px 16px rgba(0,0,0,0.3)";
item.style.maxWidth = "320px";
item.style.pointerEvents = "none";
item.style.backdropFilter = "blur(6px)";
const strong = document.createElement("div");
strong.textContent = title || "";
strong.style.fontWeight = "600";
strong.style.marginBottom = message ? "2px" : "0";
const detail = document.createElement("div");
detail.textContent = message || "";
detail.style.opacity = "0.9";
detail.style.fontSize = "12px";
item.appendChild(strong);
if (message) item.appendChild(detail);
container.appendChild(item);
const life = Number(opts.life || 4000);
setTimeout(() => {
if (item.parentNode) item.remove();
}, life);
} catch (_) {}
}
+729
View File
@@ -0,0 +1,729 @@
import { ensureStyles } from "./toast.js";
import {
extractFilenameFromUrl,
getAllowedExtensions,
hasAllowedExtension,
isValidUrl,
} from "./model_downloader.js";
function formatRecipeText(recipe) {
const parts = [];
if (recipe?.url) parts.push(recipe.url);
if (recipe?.filename) parts.push(`file: ${recipe.filename}`);
if (recipe?.kind) parts.push(`kind: ${recipe.kind}`);
if (recipe?.sha256) parts.push(`sha256: ${recipe.sha256}`);
return parts.join(" | ");
}
function showSearchResultsDialog(results, callback) {
ensureStyles();
const overlay = document.createElement("div");
overlay.style.position = "fixed";
overlay.style.inset = "0";
overlay.style.background = "rgba(5, 6, 10, 0.85)";
overlay.style.zIndex = "10011";
overlay.style.display = "flex";
overlay.style.alignItems = "center";
overlay.style.justifyContent = "center";
overlay.style.padding = "16px";
const panel = document.createElement("div");
panel.style.width = "min(600px, 90vw)";
panel.style.maxHeight = "70vh";
panel.style.overflow = "auto";
panel.style.background = "rgba(18, 20, 26, 0.98)";
panel.style.border = "1px solid rgba(255,255,255,0.12)";
panel.style.borderRadius = "12px";
panel.style.padding = "16px";
panel.style.display = "flex";
panel.style.flexDirection = "column";
panel.style.gap = "12px";
panel.style.color = "#eee";
panel.style.fontSize = "12px";
panel.style.boxShadow = "0 12px 32px rgba(0,0,0,0.5)";
overlay.appendChild(panel);
const title = document.createElement("div");
title.textContent = `Search Results (${results.length})`;
title.style.fontWeight = "600";
title.style.fontSize = "14px";
title.style.letterSpacing = "0.4px";
panel.appendChild(title);
const resultsList = document.createElement("div");
resultsList.style.display = "flex";
resultsList.style.flexDirection = "column";
resultsList.style.gap = "10px";
for (const result of results) {
const resultCard = document.createElement("div");
resultCard.style.padding = "10px";
resultCard.style.border = "1px solid rgba(255,255,255,0.1)";
resultCard.style.borderRadius = "8px";
resultCard.style.background = "rgba(12, 14, 18, 0.6)";
resultCard.style.cursor = "pointer";
resultCard.style.transition = "all 0.2s";
resultCard.addEventListener("mouseenter", () => {
resultCard.style.border = "1px solid rgba(46, 136, 255, 0.6)";
resultCard.style.background = "rgba(46, 136, 255, 0.1)";
});
resultCard.addEventListener("mouseleave", () => {
resultCard.style.border = "1px solid rgba(255,255,255,0.1)";
resultCard.style.background = "rgba(12, 14, 18, 0.6)";
});
const nameRow = document.createElement("div");
nameRow.style.fontWeight = "600";
nameRow.style.marginBottom = "6px";
nameRow.style.display = "flex";
nameRow.style.alignItems = "center";
nameRow.style.gap = "8px";
nameRow.style.flexWrap = "wrap";
// Match score badge
const scoreBadge = document.createElement("span");
const score = result.match_score || 0;
const matchLevel = result.match_level || "";
scoreBadge.textContent = `${Math.round(score)}%`;
scoreBadge.title = matchLevel;
scoreBadge.style.fontSize = "9px";
scoreBadge.style.padding = "2px 6px";
scoreBadge.style.borderRadius = "4px";
scoreBadge.style.fontWeight = "700";
// Color code by score
if (score >= 90) {
scoreBadge.style.background = "rgba(46, 204, 113, 0.25)";
scoreBadge.style.border = "1px solid rgba(46, 204, 113, 0.6)";
scoreBadge.style.color = "#7fffc4";
} else if (score >= 70) {
scoreBadge.style.background = "rgba(52, 152, 219, 0.25)";
scoreBadge.style.border = "1px solid rgba(52, 152, 219, 0.6)";
scoreBadge.style.color = "#99d9ff";
} else if (score >= 50) {
scoreBadge.style.background = "rgba(241, 196, 15, 0.25)";
scoreBadge.style.border = "1px solid rgba(241, 196, 15, 0.6)";
scoreBadge.style.color = "#ffe680";
} else {
scoreBadge.style.background = "rgba(231, 76, 60, 0.25)";
scoreBadge.style.border = "1px solid rgba(231, 76, 60, 0.6)";
scoreBadge.style.color = "#ffb3a8";
}
const platformBadge = document.createElement("span");
platformBadge.textContent = result.platform?.toUpperCase() || "UNKNOWN";
platformBadge.style.fontSize = "9px";
platformBadge.style.padding = "2px 6px";
platformBadge.style.borderRadius = "4px";
platformBadge.style.fontWeight = "700";
if (result.platform === "civitai") {
platformBadge.style.background = "rgba(255, 99, 71, 0.2)";
platformBadge.style.border = "1px solid rgba(255, 99, 71, 0.5)";
platformBadge.style.color = "#ffb3a3";
} else if (result.platform === "huggingface") {
platformBadge.style.background = "rgba(255, 215, 0, 0.2)";
platformBadge.style.border = "1px solid rgba(255, 215, 0, 0.5)";
platformBadge.style.color = "#ffe680";
} else if (result.platform === "github") {
platformBadge.style.background = "rgba(138, 43, 226, 0.2)";
platformBadge.style.border = "1px solid rgba(138, 43, 226, 0.5)";
platformBadge.style.color = "#d8b3ff";
} else if (result.platform === "web_search" || result.platform === "web") {
platformBadge.style.background = "rgba(52, 152, 219, 0.2)";
platformBadge.style.border = "1px solid rgba(52, 152, 219, 0.5)";
platformBadge.style.color = "#99d9ff";
platformBadge.textContent = "WEB";
}
const nameText = document.createElement("span");
nameText.textContent = result.name || "Unnamed";
nameText.style.flex = "1";
nameRow.appendChild(scoreBadge);
nameRow.appendChild(platformBadge);
nameRow.appendChild(nameText);
const infoRow = document.createElement("div");
infoRow.style.fontSize = "10px";
infoRow.style.opacity = "0.8";
infoRow.style.marginTop = "4px";
const infoParts = [];
if (result.type) infoParts.push(`Type: ${result.type}`);
if (result.version) infoParts.push(`Ver: ${result.version}`);
if (result.size_mb) infoParts.push(`Size: ${result.size_mb}MB`);
if (result.filename) infoParts.push(`File: ${result.filename}`);
infoRow.textContent = infoParts.join(" | ");
resultCard.appendChild(nameRow);
resultCard.appendChild(infoRow);
resultCard.addEventListener("click", () => {
overlay.remove();
callback(result);
});
resultsList.appendChild(resultCard);
}
panel.appendChild(resultsList);
const cancelBtn = document.createElement("button");
cancelBtn.textContent = "Cancel";
cancelBtn.className = "mjr-ps-btn";
cancelBtn.style.padding = "6px 12px";
cancelBtn.style.borderRadius = "8px";
cancelBtn.style.border = "1px solid rgba(255,255,255,0.2)";
cancelBtn.style.background = "rgba(255,255,255,0.06)";
cancelBtn.style.color = "#eee";
cancelBtn.style.cursor = "pointer";
cancelBtn.style.alignSelf = "flex-end";
cancelBtn.addEventListener("click", () => {
overlay.remove();
callback(null);
});
panel.appendChild(cancelBtn);
overlay.addEventListener("click", (e) => {
if (e.target === overlay) {
overlay.remove();
callback(null);
}
});
document.body.appendChild(overlay);
}
export function showModelDownloaderDialog({ entries, kindOptions, existingMap } = {}) {
ensureStyles();
const items = Array.isArray(entries) ? entries : [];
const kinds = Array.isArray(kindOptions) ? kindOptions : [];
const existing = existingMap instanceof Map ? existingMap : null;
return new Promise((resolve) => {
const overlay = document.createElement("div");
overlay.style.position = "fixed";
overlay.style.inset = "0";
overlay.style.background = "rgba(5, 6, 10, 0.75)";
overlay.style.zIndex = "10010";
overlay.style.display = "flex";
overlay.style.alignItems = "center";
overlay.style.justifyContent = "center";
overlay.style.padding = "16px";
const panel = document.createElement("div");
panel.style.width = "min(720px, 92vw)";
panel.style.maxHeight = "80vh";
panel.style.overflow = "auto";
panel.style.background = "rgba(18, 20, 26, 0.95)";
panel.style.border = "1px solid rgba(255,255,255,0.12)";
panel.style.borderRadius = "12px";
panel.style.padding = "14px 16px";
panel.style.display = "flex";
panel.style.flexDirection = "column";
panel.style.gap = "10px";
panel.style.color = "#eee";
panel.style.fontSize = "12px";
panel.style.boxShadow = "0 12px 32px rgba(0,0,0,0.4)";
overlay.appendChild(panel);
const title = document.createElement("div");
title.textContent = "Download Missing Models";
title.style.fontWeight = "600";
title.style.fontSize = "13px";
title.style.letterSpacing = "0.4px";
panel.appendChild(title);
const subtitle = document.createElement("div");
subtitle.textContent = `Missing entries: ${items.length}`;
subtitle.style.opacity = "0.8";
subtitle.style.fontSize = "11px";
panel.appendChild(subtitle);
const list = document.createElement("div");
list.style.display = "flex";
list.style.flexDirection = "column";
list.style.gap = "8px";
panel.appendChild(list);
const rows = [];
for (const entry of items) {
const recipe = entry?.recipe || null;
const isPrefilled = entry?.prefilled === true || !!recipe;
const isNoteRecipe = recipe?.source === "workflow_note";
const row = document.createElement("div");
row.style.display = "flex";
row.style.flexDirection = "column";
row.style.gap = "6px";
row.style.padding = "8px";
row.style.borderRadius = "8px";
row.style.border = "1px solid rgba(255,255,255,0.08)";
row.style.background = "rgba(12, 14, 18, 0.6)";
const defaultBorder = row.style.border;
const defaultBackground = row.style.background;
const header = document.createElement("div");
header.style.display = "flex";
header.style.alignItems = "center";
header.style.justifyContent = "space-between";
header.style.gap = "8px";
const titleWrap = document.createElement("div");
titleWrap.style.display = "flex";
titleWrap.style.alignItems = "center";
titleWrap.style.gap = "6px";
const titleText = document.createElement("div");
titleText.textContent = entry?.missing_value || entry?.key || "Missing model";
titleText.style.fontWeight = "600";
titleWrap.appendChild(titleText);
if (isNoteRecipe) {
const badge = document.createElement("span");
badge.textContent = "From Workflow Note";
badge.style.fontSize = "10px";
badge.style.padding = "1px 6px";
badge.style.borderRadius = "999px";
badge.style.background = "rgba(46, 136, 255, 0.18)";
badge.style.border = "1px solid rgba(46, 136, 255, 0.45)";
badge.style.color = "#cfe3ff";
titleWrap.appendChild(badge);
}
const downloadWrap = document.createElement("label");
downloadWrap.style.display = "flex";
downloadWrap.style.alignItems = "center";
downloadWrap.style.gap = "6px";
downloadWrap.style.cursor = "pointer";
const downloadCb = document.createElement("input");
downloadCb.type = "checkbox";
downloadCb.checked = isPrefilled;
const downloadText = document.createElement("span");
downloadText.textContent = "Download";
downloadText.style.opacity = "0.85";
downloadWrap.appendChild(downloadCb);
downloadWrap.appendChild(downloadText);
header.appendChild(titleWrap);
header.appendChild(downloadWrap);
row.appendChild(header);
const separator = document.createElement("div");
separator.style.height = "1px";
separator.style.background = "rgba(255,255,255,0.08)";
separator.style.margin = "2px 0";
row.appendChild(separator);
const urlLabel = document.createElement("div");
urlLabel.textContent = "URL";
urlLabel.style.opacity = "0.8";
urlLabel.style.fontSize = "10px";
const urlField = document.createElement("input");
urlField.type = "text";
urlField.placeholder = "https://...";
urlField.style.width = "100%";
urlField.style.padding = "6px 8px";
urlField.style.borderRadius = "8px";
urlField.style.border = "1px solid rgba(255,255,255,0.12)";
urlField.style.background = "rgba(0,0,0,0.25)";
urlField.style.color = "#eee";
const kindLabel = document.createElement("div");
kindLabel.textContent = "Kind";
kindLabel.style.opacity = "0.8";
kindLabel.style.fontSize = "10px";
const kindField = document.createElement("select");
kindField.className = "mjr-ps-select";
kindField.style.width = "100%";
kindField.style.padding = "6px 8px";
kindField.style.borderRadius = "8px";
kindField.style.border = "1px solid rgba(255,255,255,0.12)";
kindField.style.color = "#f2f4f8";
for (const option of kinds) {
const opt = document.createElement("option");
opt.value = option.value;
opt.textContent = option.label || option.value;
kindField.appendChild(opt);
}
const fileLabel = document.createElement("div");
fileLabel.textContent = "Filename (optional)";
fileLabel.style.opacity = "0.8";
fileLabel.style.fontSize = "10px";
const fileField = document.createElement("input");
fileField.type = "text";
fileField.placeholder = getAllowedExtensions().join(", ");
fileField.style.width = "100%";
fileField.style.padding = "6px 8px";
fileField.style.borderRadius = "8px";
fileField.style.border = "1px solid rgba(255,255,255,0.12)";
fileField.style.background = "rgba(0,0,0,0.25)";
fileField.style.color = "#eee";
const shaLabel = document.createElement("div");
shaLabel.textContent = "SHA256 (optional)";
shaLabel.style.opacity = "0.8";
shaLabel.style.fontSize = "10px";
const shaField = document.createElement("input");
shaField.type = "text";
shaField.placeholder = "64 hex chars";
shaField.style.width = "100%";
shaField.style.padding = "6px 8px";
shaField.style.borderRadius = "8px";
shaField.style.border = "1px solid rgba(255,255,255,0.12)";
shaField.style.background = "rgba(0,0,0,0.25)";
shaField.style.color = "#eee";
const grid = document.createElement("div");
grid.style.display = "grid";
grid.style.gridTemplateColumns = "1fr 1fr";
grid.style.gap = "6px";
const colLeft = document.createElement("div");
colLeft.style.display = "flex";
colLeft.style.flexDirection = "column";
colLeft.style.gap = "4px";
colLeft.appendChild(urlLabel);
colLeft.appendChild(urlField);
colLeft.appendChild(fileLabel);
colLeft.appendChild(fileField);
const colRight = document.createElement("div");
colRight.style.display = "flex";
colRight.style.flexDirection = "column";
colRight.style.gap = "4px";
colRight.appendChild(kindLabel);
colRight.appendChild(kindField);
colRight.appendChild(shaLabel);
colRight.appendChild(shaField);
grid.appendChild(colLeft);
grid.appendChild(colRight);
row.appendChild(grid);
// Add "Search Online" button
const searchBtnWrap = document.createElement("div");
searchBtnWrap.style.marginTop = "4px";
const searchBtn = document.createElement("button");
searchBtn.textContent = "🔍 Search Online";
searchBtn.className = "mjr-ps-btn";
searchBtn.style.padding = "4px 8px";
searchBtn.style.borderRadius = "6px";
searchBtn.style.border = "1px solid rgba(46, 136, 255, 0.4)";
searchBtn.style.background = "rgba(46, 136, 255, 0.15)";
searchBtn.style.color = "#eee";
searchBtn.style.cursor = "pointer";
searchBtn.style.fontSize = "11px";
searchBtn.style.width = "100%";
searchBtn.addEventListener("click", async () => {
// Extract model name from missing_value or key
const searchQuery = (entry?.key || entry?.missing_value || "")
.replace(/\.(safetensors|ckpt|pt|pth|bin)$/i, "");
if (!searchQuery) {
alert("Cannot determine model name to search for");
return;
}
searchBtn.disabled = true;
searchBtn.textContent = "⏳ Searching...";
try {
const response = await fetch("/mjr_models/search_online", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: searchQuery, limit: 3 })
});
const data = await response.json();
if (!data.ok) {
alert("Search failed");
return;
}
const results = data.results;
// Use sorted_results if available (sorted by match score)
const allResults = results.sorted_results || [
...(results.platforms?.civitai || []),
...(results.platforms?.huggingface || []),
...(results.platforms?.github || [])
];
// Check if we should show "Search on Google" button
const googleUrl = results.google_search_url;
if (allResults.length === 0) {
const message = `No results found for "${searchQuery}"\n\nTry:\n- Simplifying the search query\n- Using fewer words\n- Checking the spelling\n\nOr search on Google?`;
if (confirm(message)) {
// Open Google search in new tab
if (googleUrl) {
window.open(googleUrl, '_blank');
}
}
return;
}
// Show results dialog (with Google button if few results)
showSearchResultsDialog(allResults, (selectedResult) => {
if (selectedResult) {
urlField.value = selectedResult.url;
if (selectedResult.filename) fileField.value = selectedResult.filename;
if (selectedResult.sha256) shaField.value = selectedResult.sha256;
if (selectedResult.type) setKindValue(selectedResult.type);
downloadCb.checked = true;
updateExistsHighlight();
}
});
} catch (error) {
console.error("Search error:", error);
alert("Search error: " + error.message);
} finally {
searchBtn.disabled = false;
searchBtn.textContent = "🔍 Search Online";
}
});
searchBtnWrap.appendChild(searchBtn);
row.appendChild(searchBtnWrap);
if (recipe) {
const recipeLine = document.createElement("div");
recipeLine.textContent = formatRecipeText(recipe);
recipeLine.style.opacity = "0.75";
recipeLine.style.fontSize = "10px";
recipeLine.style.wordBreak = "break-word";
row.appendChild(recipeLine);
}
if (recipe?.url) {
urlField.value = recipe.url;
}
if (recipe?.filename) {
fileField.value = recipe.filename;
}
if (recipe?.sha256) {
shaField.value = recipe.sha256;
}
const setKindValue = (value) => {
if (!value) return false;
const exists = Array.from(kindField.options).some((opt) => opt.value === value);
if (exists) {
kindField.value = value;
return true;
}
return false;
};
if (!setKindValue(recipe?.kind)) {
setKindValue(entry?.kind);
}
let locked = isPrefilled;
const setLocked = (value) => {
locked = value;
urlField.readOnly = locked;
fileField.readOnly = locked;
shaField.readOnly = locked;
kindField.disabled = locked;
};
setLocked(isPrefilled);
if (isNoteRecipe) {
const editBtn = document.createElement("button");
editBtn.textContent = "Edit";
editBtn.className = "mjr-ps-btn";
editBtn.style.padding = "2px 6px";
editBtn.style.borderRadius = "6px";
editBtn.style.border = "1px solid rgba(255,255,255,0.2)";
editBtn.style.background = "rgba(255,255,255,0.06)";
editBtn.style.color = "#eee";
editBtn.style.cursor = "pointer";
editBtn.addEventListener("click", () => {
setLocked(!locked);
editBtn.textContent = locked ? "Edit" : "Lock";
});
titleWrap.appendChild(editBtn);
}
const normalizeName = (value) => String(value || "").replace(/\\/g, "/").toLowerCase();
const existsFor = (kind, filename) => {
if (!existing || !kind || !filename) return false;
const set = existing.get(kind);
if (!set) return false;
return set.has(normalizeName(filename));
};
const updateExistsHighlight = () => {
const kind = String(kindField.value || "").trim();
const filename = String(fileField.value || extractFilenameFromUrl(urlField.value || "") || "").trim();
if (!filename) {
row.style.border = defaultBorder;
row.style.background = defaultBackground;
return;
}
if (existsFor(kind, filename)) {
row.style.border = "1px solid rgba(47, 143, 78, 0.7)";
row.style.background = "rgba(47, 143, 78, 0.12)";
} else {
row.style.border = defaultBorder;
row.style.background = defaultBackground;
}
};
urlField.addEventListener("input", updateExistsHighlight);
fileField.addEventListener("input", updateExistsHighlight);
kindField.addEventListener("change", updateExistsHighlight);
updateExistsHighlight();
list.appendChild(row);
rows.push({
entry,
downloadCb,
urlInput: urlField,
kindSelect: kindField,
filenameInput: fileField,
shaInput: shaField,
});
}
const rememberWrap = document.createElement("label");
rememberWrap.style.display = "flex";
rememberWrap.style.alignItems = "center";
rememberWrap.style.gap = "6px";
rememberWrap.style.cursor = "pointer";
const rememberCb = document.createElement("input");
rememberCb.type = "checkbox";
rememberCb.checked = true;
const rememberText = document.createElement("span");
rememberText.textContent = "Remember these sources";
rememberText.style.opacity = "0.85";
rememberWrap.appendChild(rememberCb);
rememberWrap.appendChild(rememberText);
panel.appendChild(rememberWrap);
const errorText = document.createElement("div");
errorText.style.color = "#f2a5a5";
errorText.style.fontSize = "11px";
errorText.style.minHeight = "14px";
panel.appendChild(errorText);
const actions = document.createElement("div");
actions.style.display = "flex";
actions.style.gap = "8px";
actions.style.justifyContent = "flex-end";
const cancelBtn = document.createElement("button");
cancelBtn.textContent = "Cancel";
cancelBtn.className = "mjr-ps-btn";
cancelBtn.style.padding = "6px 10px";
cancelBtn.style.borderRadius = "8px";
cancelBtn.style.border = "1px solid rgba(255,255,255,0.2)";
cancelBtn.style.background = "rgba(255,255,255,0.06)";
cancelBtn.style.color = "#eee";
cancelBtn.style.cursor = "pointer";
const downloadBtn = document.createElement("button");
downloadBtn.textContent = "Download Selected";
downloadBtn.className = "mjr-ps-btn";
downloadBtn.style.padding = "6px 10px";
downloadBtn.style.borderRadius = "8px";
downloadBtn.style.border = "1px solid rgba(46, 136, 255, 0.65)";
downloadBtn.style.background = "rgba(46, 136, 255, 0.25)";
downloadBtn.style.color = "#eee";
downloadBtn.style.cursor = "pointer";
actions.appendChild(cancelBtn);
actions.appendChild(downloadBtn);
panel.appendChild(actions);
const close = (value) => {
document.removeEventListener("keydown", onKeyDown);
if (overlay.parentNode) overlay.remove();
resolve(value);
};
const onKeyDown = (event) => {
if (String(event.key || "").toLowerCase() === "escape") {
event.preventDefault();
close(null);
}
};
downloadBtn.addEventListener("click", () => {
errorText.textContent = "";
const downloadItems = [];
const saveItems = [];
for (const row of rows) {
if (!row.downloadCb.checked) continue;
const entry = row.entry || {};
const url = String(row.urlInput?.value || "").trim();
const kind = String(row.kindSelect?.value || "").trim();
const filenameRaw = String(row.filenameInput?.value || "").trim();
const shaRaw = String(row.shaInput?.value || "").trim().toLowerCase();
if (!url || !isValidUrl(url)) {
errorText.textContent = "Please provide a valid http/https URL.";
return;
}
const filename = filenameRaw || extractFilenameFromUrl(url);
if (!filename || !hasAllowedExtension(filename)) {
errorText.textContent = "Filename must use a supported extension.";
return;
}
if (!kind) {
errorText.textContent = "Please select a valid kind.";
return;
}
if (shaRaw && !/^[0-9a-f]{64}$/.test(shaRaw)) {
errorText.textContent = "SHA256 must be 64 hex characters.";
return;
}
const item = {
key: entry.key,
kind,
url,
filename,
sha256: shaRaw || null,
};
downloadItems.push(item);
if (rememberCb.checked) {
saveItems.push(item);
}
}
if (!downloadItems.length) {
errorText.textContent = "Select at least one item to download.";
return;
}
close({
items: downloadItems,
saveItems,
remember: rememberCb.checked,
});
});
cancelBtn.addEventListener("click", () => close(null));
overlay.addEventListener("click", (event) => {
if (event.target === overlay) {
close(null);
}
});
document.addEventListener("keydown", onKeyDown);
document.body.appendChild(overlay);
});
}
+335
View File
@@ -0,0 +1,335 @@
import { ensureStyles } from "./toast.js";
function buildResultMap(results) {
const map = new Map();
for (const entry of results || []) {
const missingValue = String(entry?.missing_value || "");
const typeHint = String(entry?.type_hint || "unknown");
const key = `${missingValue}::${typeHint}`;
map.set(key, {
candidates: Array.isArray(entry?.candidates) ? entry.candidates : [],
exact_match_wrong_folder: entry?.exact_match_wrong_folder || null,
});
}
return map;
}
function formatCandidateLabel(candidate) {
const score = Number(candidate?.score || 0);
const basename = String(candidate?.basename || candidate?.relpath || "");
const kind = String(candidate?.kind || "");
if (kind) {
return `${score} | ${basename} (${kind})`;
}
return `${score} | ${basename}`;
}
export function showModelFixerDialog({ missing, results } = {}) {
ensureStyles();
const missingList = Array.isArray(missing) ? missing : [];
const resultMap = buildResultMap(results);
return new Promise((resolve) => {
const overlay = document.createElement("div");
overlay.style.position = "fixed";
overlay.style.inset = "0";
overlay.style.background = "rgba(5, 6, 10, 0.75)";
overlay.style.zIndex = "10010";
overlay.style.display = "flex";
overlay.style.alignItems = "center";
overlay.style.justifyContent = "center";
overlay.style.padding = "16px";
const panel = document.createElement("div");
panel.style.width = "min(640px, 92vw)";
panel.style.maxHeight = "80vh";
panel.style.overflow = "auto";
panel.style.background = "rgba(18, 20, 26, 0.95)";
panel.style.border = "1px solid rgba(255,255,255,0.12)";
panel.style.borderRadius = "12px";
panel.style.padding = "14px 16px";
panel.style.display = "flex";
panel.style.flexDirection = "column";
panel.style.gap = "10px";
panel.style.color = "#eee";
panel.style.fontSize = "12px";
panel.style.boxShadow = "0 12px 32px rgba(0,0,0,0.4)";
overlay.appendChild(panel);
const title = document.createElement("div");
title.textContent = "Fix Missing Models";
title.style.fontWeight = "600";
title.style.fontSize = "13px";
title.style.letterSpacing = "0.4px";
panel.appendChild(title);
const subtitle = document.createElement("div");
subtitle.textContent = `Missing entries: ${missingList.length}`;
subtitle.style.opacity = "0.8";
subtitle.style.fontSize = "11px";
panel.appendChild(subtitle);
const list = document.createElement("div");
list.style.display = "flex";
list.style.flexDirection = "column";
list.style.gap = "8px";
panel.appendChild(list);
const rows = [];
for (const entry of missingList) {
const missingValue = String(entry?.missing_value || "");
const typeHint = String(entry?.type_hint || "unknown");
const key = `${missingValue}::${typeHint}`;
const resultData = resultMap.get(key) || { candidates: [], exact_match_wrong_folder: null };
const candidates = resultData.candidates || [];
const wrongFolderMatch = resultData.exact_match_wrong_folder;
const exactMatches = candidates.filter((c) => Number(c?.score || 0) === 100);
const exactUnique = exactMatches.length === 1;
const row = document.createElement("div");
row.style.display = "flex";
row.style.flexDirection = "column";
row.style.gap = "6px";
row.style.padding = "8px";
row.style.borderRadius = "8px";
row.style.border = "1px solid rgba(255,255,255,0.08)";
row.style.background = "rgba(12, 14, 18, 0.6)";
const label = document.createElement("div");
label.style.display = "flex";
label.style.alignItems = "center";
label.style.gap = "6px";
const titleText = document.createElement("div");
const nodeLabel = entry?.node_title || `Node ${entry?.node_id ?? "?"}`;
const widgetLabel = entry?.widget_name || "widget";
titleText.textContent = `${nodeLabel} - ${widgetLabel}`;
titleText.style.fontWeight = "600";
const autoTag = document.createElement("span");
if (exactUnique) {
autoTag.textContent = "auto";
autoTag.style.fontSize = "10px";
autoTag.style.padding = "1px 6px";
autoTag.style.borderRadius = "999px";
autoTag.style.background = "rgba(46, 136, 255, 0.25)";
autoTag.style.border = "1px solid rgba(46, 136, 255, 0.45)";
autoTag.style.color = "#cfe3ff";
}
label.appendChild(titleText);
if (exactUnique) {
label.appendChild(autoTag);
}
const missingLine = document.createElement("div");
missingLine.textContent = `Missing: ${missingValue}`;
missingLine.style.opacity = "0.8";
missingLine.style.fontSize = "11px";
// Show "Move to correct folder" button if exact match found in wrong folder
let moveButton = null;
if (wrongFolderMatch) {
const moveContainer = document.createElement("div");
moveContainer.style.display = "flex";
moveContainer.style.alignItems = "center";
moveContainer.style.gap = "8px";
moveContainer.style.padding = "6px 8px";
moveContainer.style.borderRadius = "8px";
moveContainer.style.background = "rgba(255, 165, 0, 0.15)";
moveContainer.style.border = "1px solid rgba(255, 165, 0, 0.3)";
const moveInfo = document.createElement("div");
moveInfo.style.flex = "1";
moveInfo.style.fontSize = "11px";
moveInfo.style.color = "#ffb74d";
moveInfo.textContent = `Found in ${wrongFolderMatch.kind}/ → Move to ${wrongFolderMatch.expected_kind}/`;
moveButton = document.createElement("button");
moveButton.textContent = "Move";
moveButton.className = "mjr-ps-btn";
moveButton.style.padding = "4px 10px";
moveButton.style.borderRadius = "6px";
moveButton.style.border = "1px solid rgba(255, 165, 0, 0.5)";
moveButton.style.background = "rgba(255, 165, 0, 0.25)";
moveButton.style.color = "#ffcc80";
moveButton.style.cursor = "pointer";
moveButton.style.fontSize = "11px";
moveButton.dataset.sourceKind = wrongFolderMatch.kind;
moveButton.dataset.sourceRelpath = wrongFolderMatch.relpath;
moveButton.dataset.targetKind = wrongFolderMatch.expected_kind;
moveContainer.appendChild(moveInfo);
moveContainer.appendChild(moveButton);
row.appendChild(label);
row.appendChild(missingLine);
row.appendChild(moveContainer);
} else {
row.appendChild(label);
row.appendChild(missingLine);
}
const select = document.createElement("select");
select.className = "mjr-ps-select";
select.style.width = "100%";
select.style.padding = "6px 8px";
select.style.borderRadius = "8px";
select.style.border = "1px solid rgba(255,255,255,0.12)";
select.style.color = "#f2f4f8";
const emptyOpt = document.createElement("option");
emptyOpt.value = "";
emptyOpt.textContent = candidates.length ? "-- Select match --" : "No candidates found";
select.appendChild(emptyOpt);
for (const candidate of candidates) {
const opt = document.createElement("option");
opt.value = String(candidate?.relpath || "");
opt.textContent = formatCandidateLabel(candidate);
opt.title = String(candidate?.relpath || "");
select.appendChild(opt);
}
if (exactUnique) {
select.value = String(exactMatches[0]?.relpath || "");
}
if (!candidates.length) {
select.disabled = true;
select.style.opacity = "0.6";
}
row.appendChild(select);
list.appendChild(row);
rows.push({
node_id: entry?.node_id,
widget_name: entry?.widget_name,
select,
moveButton,
});
}
const actions = document.createElement("div");
actions.style.display = "flex";
actions.style.gap = "8px";
actions.style.justifyContent = "flex-end";
actions.style.marginTop = "6px";
const cancelBtn = document.createElement("button");
cancelBtn.textContent = "Cancel";
cancelBtn.className = "mjr-ps-btn";
cancelBtn.style.padding = "6px 10px";
cancelBtn.style.borderRadius = "8px";
cancelBtn.style.border = "1px solid rgba(255,255,255,0.2)";
cancelBtn.style.background = "rgba(255,255,255,0.06)";
cancelBtn.style.color = "#eee";
cancelBtn.style.cursor = "pointer";
const applyBtn = document.createElement("button");
applyBtn.textContent = "Apply Fix";
applyBtn.className = "mjr-ps-btn";
applyBtn.style.padding = "6px 10px";
applyBtn.style.borderRadius = "8px";
applyBtn.style.border = "1px solid rgba(46, 136, 255, 0.65)";
applyBtn.style.background = "rgba(46, 136, 255, 0.25)";
applyBtn.style.color = "#eee";
applyBtn.style.cursor = "pointer";
actions.appendChild(cancelBtn);
actions.appendChild(applyBtn);
panel.appendChild(actions);
const close = (value) => {
document.removeEventListener("keydown", onKeyDown);
if (overlay.parentNode) overlay.remove();
resolve(value);
};
const onKeyDown = (event) => {
if (String(event.key || "").toLowerCase() === "escape") {
event.preventDefault();
close(null);
}
};
// Add event listeners to all move buttons
for (const row of rows) {
if (row.moveButton) {
row.moveButton.addEventListener("click", async () => {
const button = row.moveButton;
const sourceKind = button.dataset.sourceKind;
const sourceRelpath = button.dataset.sourceRelpath;
const targetKind = button.dataset.targetKind;
button.disabled = true;
button.textContent = "Moving...";
try {
const response = await fetch("/mjr_models/move_to_correct_folder", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
source_kind: sourceKind,
source_relpath: sourceRelpath,
target_kind: targetKind,
}),
});
const data = await response.json();
if (data.ok) {
button.textContent = "Moved!";
button.style.background = "rgba(76, 175, 80, 0.25)";
button.style.border = "1px solid rgba(76, 175, 80, 0.5)";
button.style.color = "#a5d6a7";
// Auto-select the newly moved file in the dropdown
if (row.select && data.target_relpath) {
row.select.value = data.target_relpath;
}
// Refresh the folder to show the new file
setTimeout(() => {
if (typeof app?.refreshComboInNodes === "function") {
app.refreshComboInNodes();
}
}, 500);
} else {
throw new Error(data.error || "Move failed");
}
} catch (error) {
console.error("[mjr] Failed to move model:", error);
button.textContent = "Error";
button.style.background = "rgba(244, 67, 54, 0.25)";
button.style.border = "1px solid rgba(244, 67, 54, 0.5)";
button.disabled = false;
setTimeout(() => {
button.textContent = "Move";
button.style.background = "rgba(255, 165, 0, 0.25)";
button.style.border = "1px solid rgba(255, 165, 0, 0.5)";
}, 3000);
}
});
}
}
applyBtn.addEventListener("click", () => {
const fixes = [];
for (const row of rows) {
const value = row.select.value;
if (!value) continue;
fixes.push({
node_id: row.node_id,
widget_name: row.widget_name,
new_value: value,
});
}
close(fixes);
});
cancelBtn.addEventListener("click", () => close(null));
overlay.addEventListener("click", (event) => {
if (event.target === overlay) {
close(null);
}
});
document.addEventListener("keydown", onKeyDown);
document.body.appendChild(overlay);
});
}
+136
View File
@@ -0,0 +1,136 @@
export function titlePathJS(text) {
if (text == null) return "";
let t = String(text).trim();
if (t.normalize) {
t = t.normalize("NFKD").replace(/[^\x00-\x7F]/g, "");
}
t = t.replace(/[\\/]/g, " ");
const tokens = t.split(/[\s_-]+/).filter(Boolean);
const titled = tokens.map((tok) => {
if (tok.length <= 1) return tok.toUpperCase();
return tok[0].toUpperCase() + tok.slice(1).toLowerCase();
});
return titled.join("_") || "Project";
}
export function token3Tag(raw, upper) {
if (raw == null) return "";
let t = String(raw).trim();
if (!t) return "";
if (t.normalize) {
t = t.normalize("NFKD").replace(/[^\x00-\x7F]/g, "");
}
t = t.replace(/\\/g, "/").split("/").pop();
const lastDot = t.lastIndexOf(".");
if (lastDot > 0) {
t = t.slice(0, lastDot);
}
const tokens = t.split(/[\s_-]+/).filter(Boolean).slice(0, 3);
if (!tokens.length) return "";
if (upper) {
return tokens.join("_").toUpperCase();
}
return tokens
.map((tok) =>
tok.length <= 1 ? tok.toUpperCase() : tok[0].toUpperCase() + tok.slice(1).toLowerCase()
)
.join("_");
}
export function yymmddJS() {
const d = new Date();
const yy = String(d.getFullYear()).slice(-2);
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${yy}${mm}${dd}`;
}
export function safeRel(rel) {
if (rel == null) return "";
let r = String(rel).replace(/\\/g, "/").trim();
r = r.replace(/^output\//i, "");
r = r.replace(/^\/+/, "");
r = r
.split("/")
.filter((p) => p && p !== "." && p !== "..")
.join("/");
return r;
}
export function joinRel(a, b) {
const left = safeRel(a);
const right = safeRel(b);
if (!left) return right;
if (!right) return left;
return `${left.replace(/\/+$/, "")}/${right.replace(/^\/+/, "")}`;
}
export function mediaDir(media) {
const m = String(media || "").toLowerCase();
if (m === "videos") return "02_OUT/VIDEOS";
if (m === "images") return "02_OUT/IMAGES";
return "02_OUT/OTHER";
}
export function makeKindToken(kind) {
const k = String(kind || "").toLowerCase();
if (k === "asset") return "ASSET";
if (k === "shot") return "SHOT";
return "MISC";
}
/**
* Validate that a path is relative and safe.
*/
function validateRelativePath(path, context) {
if (path.startsWith("/") || path.startsWith("\\")) {
throw new Error(`${context} must be relative`);
}
if (path.includes(":")) {
throw new Error(`${context} must be relative`);
}
if (path.includes("..")) {
throw new Error(`${context} contains '..'`);
}
}
export function resolveTemplatePreview(template, tokens) {
if (!template) return "";
let out = template.replace(/\\/g, "/").trim();
if (!out) return "";
// Validate template before processing
validateRelativePath(out, "template");
if (!out.includes("{BASE}")) {
throw new Error("template must include {BASE}");
}
// Replace tokens (use split/join to handle multiple occurrences safely)
for (const [key, value] of Object.entries(tokens)) {
const safeValue = String(value || "");
// Prevent token values from containing other token patterns
if (safeValue.includes("{") || safeValue.includes("}")) {
throw new Error(`token value for {${key}} contains template markers`);
}
out = out.split(`{${key}}`).join(safeValue);
}
// Normalize slashes
out = out.replace(/\\/g, "/").replace(/\/+/g, "/");
// Validate resolved path
validateRelativePath(out, "resolved path");
const parts = out.split("/").filter(Boolean);
// Verify base constraint
const base = String(tokens?.BASE || "").replace(/\\/g, "/").replace(/\/+$/, "");
if (base) {
if (!(out === base || out.startsWith(`${base}/`))) {
throw new Error("template must stay under {BASE}");
}
}
return parts.join("/");
}
+213
View File
@@ -0,0 +1,213 @@
const NOTE_TYPE_RE = /markdown|note/i;
const NOTE_TITLE_RE = /note/i;
const ALLOWED_EXTENSIONS = [".safetensors", ".ckpt", ".pt", ".pth", ".bin"];
const SECTION_KINDS = new Set([
"text_encoders",
"clip_vision",
"loras",
"diffusion_models",
"vae",
]);
function normalizeSectionLine(line) {
let t = String(line || "").trim().toLowerCase();
if (!t) return "";
t = t.replace(/^#+\s*/, "");
t = t.replace(/:\s*$/, "");
return t.trim();
}
function normalizeUrl(url) {
let u = String(url || "").trim();
if (!u) return "";
u = u.replace(/\/blob\//i, "/resolve/");
return u;
}
function basenameFromUrl(url) {
try {
const parsed = new URL(url);
const path = parsed.pathname || "";
const parts = path.split("/").filter(Boolean);
const base = parts.length ? parts[parts.length - 1] : "";
return base;
} catch (_) {
const clean = String(url || "").split("?")[0].split("#")[0];
const parts = clean.split("/").filter(Boolean);
return parts.length ? parts[parts.length - 1] : "";
}
}
function hasAllowedExtension(filename) {
const lower = String(filename || "").toLowerCase();
return ALLOWED_EXTENSIONS.some((ext) => lower.endsWith(ext));
}
function inferKindFromUrl(url) {
const lower = String(url || "").toLowerCase();
// Check URL path for kind indicators
if (lower.includes("/text_encoders/") || lower.includes("text_encoder")) return "text_encoders";
if (lower.includes("/diffusion_models/") || lower.includes("diffusion_model")) return "diffusion_models";
if (lower.includes("/clip_vision/")) return "clip_vision";
if (lower.includes("/loras/") || lower.includes("/lora/")) return "loras";
if (lower.includes("/vae/")) return "vae";
if (lower.includes("/controlnet/")) return "controlnet";
if (lower.includes("/upscale_models/") || lower.includes("/upscale/")) return "upscale_models";
if (lower.includes("/clip/")) return "clip";
if (lower.includes("/unet/")) return "unet";
if (lower.includes("/embeddings/") || lower.includes("/embedding/")) return "embeddings";
if (lower.includes("/checkpoints/") || lower.includes("/checkpoint/")) return "checkpoints";
return "unknown";
}
function getNoteTextFromNode(node) {
const props = node?.properties || {};
const candidates = [
props?.text,
props?.value,
props?.markdown,
];
for (const candidate of candidates) {
if (typeof candidate === "string" && candidate.trim().length > 20) {
return candidate;
}
}
const widgetsValues = node?.widgets_values;
if (Array.isArray(widgetsValues)) {
const parts = widgetsValues.filter((v) => typeof v === "string" && v.trim().length > 0);
const joined = parts.join("\n");
if (joined.trim().length > 20) return joined;
}
const widgets = node?.widgets;
if (Array.isArray(widgets)) {
const parts = widgets
.map((w) => w?.value)
.filter((v) => typeof v === "string" && v.trim().length > 0);
const joined = parts.join("\n");
if (joined.trim().length > 20) return joined;
}
return "";
}
function isNoteLikeNode(node) {
const type = String(node?.type || node?.class_type || "").trim();
const title = String(node?.title || "").trim();
return NOTE_TYPE_RE.test(type) || NOTE_TITLE_RE.test(title);
}
function collectGraphsFromNode(node) {
return [
node?.subgraph,
node?._subgraph,
node?.subgraph?.graph,
node?.subgraph?.lgraph,
node?.properties?.subgraph,
node?.subgraph_instance,
node?.subgraph_instance?.graph,
].filter(Boolean);
}
function walkGraph(graph, visitor, visited) {
if (!graph || !Array.isArray(graph.nodes)) return;
if (visited.has(graph)) return;
visited.add(graph);
for (const node of graph.nodes) {
visitor(node);
const children = collectGraphsFromNode(node);
for (const child of children) {
if (Array.isArray(child?.nodes)) {
walkGraph(child, visitor, visited);
}
}
}
}
export function extractNoteTexts(workflowJson) {
if (!workflowJson || !Array.isArray(workflowJson.nodes)) return [];
const texts = new Set();
const visited = new WeakSet();
const graph = { nodes: workflowJson.nodes };
walkGraph(
graph,
(node) => {
if (!isNoteLikeNode(node)) return;
const text = getNoteTextFromNode(node);
if (text && text.trim().length > 20) {
texts.add(text.trim());
}
},
visited
);
return Array.from(texts);
}
export function parseRecipesFromNoteText(text) {
const recipes = new Map();
const lines = String(text || "").split(/\r?\n/);
let currentKind = "unknown";
for (const line of lines) {
const normalized = normalizeSectionLine(line);
if (SECTION_KINDS.has(normalized)) {
currentKind = normalized;
continue;
}
const urls = new Set();
const mdRegex = /\[[^\]]+\]\((https?:\/\/[^\s)]+)\)/g;
let match = null;
while ((match = mdRegex.exec(line)) !== null) {
if (match[1]) urls.add(match[1]);
}
const rawRegex = /(https?:\/\/[^\s]+)/g;
while ((match = rawRegex.exec(line)) !== null) {
if (match[1]) urls.add(match[1]);
}
for (const url of urls) {
const normalizedUrl = normalizeUrl(url);
if (!normalizedUrl) continue;
const filename = basenameFromUrl(normalizedUrl);
if (!filename || !hasAllowedExtension(filename)) continue;
if (recipes.has(filename)) {
console.debug("[mjr] workflow note recipe duplicate", filename);
continue;
}
// Use section kind if available, otherwise infer from URL
let finalKind = currentKind;
if (!finalKind || finalKind === "unknown") {
finalKind = inferKindFromUrl(normalizedUrl);
}
recipes.set(filename, {
key: filename,
kind: finalKind,
url: normalizedUrl,
filename,
source: "workflow_note",
});
}
}
return recipes;
}
export function collectRecipesFromWorkflowNotes(workflowJson) {
const texts = extractNoteTexts(workflowJson);
const merged = new Map();
for (const text of texts) {
const parsed = parseRecipesFromNoteText(text);
for (const [key, recipe] of parsed.entries()) {
if (!merged.has(key)) {
merged.set(key, recipe);
}
}
}
return merged;
}
+92
View File
@@ -0,0 +1,92 @@
const STORAGE_KEY = "mjr_project_settings_state";
export const DEFAULT_STATE = {
projectName: "",
workflowName: "",
workflowAsset: "",
workflowLastBase: "",
workflowNextSuffix: 1,
workflowPanelOpen: false,
projectId: "",
projectFolder: "",
projectExists: null,
autoPatch: true,
autoPatchNewNodes: true,
autoSwitchTrusted: false,
autoHookSave: true,
kind: "asset",
media: "images",
name: "",
modelSelection: "",
detectedModelTag: "",
useCustomModel: false,
customModelText: "",
modelUpper: false,
pendingProjectName: "",
pendingSelectProjectId: "",
lastRelDir: "",
lastPrefix: "",
pathTemplate: "{BASE}/{MEDIA}/{DATE}/{NAME}/{MODEL}",
showMoreModels: false,
lastError: "",
detected: null,
workflowHasSignature: false,
workflowChecked: false,
isGraphLoading: false,
graphLoadToken: 0,
graphIsEmpty: false,
};
export let runtimeState = null;
export function setRuntimeState(state) {
runtimeState = state;
}
export function createRuntimeState(overrides) {
return Object.assign({}, DEFAULT_STATE, overrides || {});
}
export function loadState() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return null;
return JSON.parse(raw);
} catch (_) {
return null;
}
}
export function saveState(state) {
const data = {
projectName: state.projectName,
workflowName: state.workflowName,
workflowAsset: state.workflowAsset,
workflowLastBase: state.workflowLastBase,
workflowNextSuffix: state.workflowNextSuffix,
workflowPanelOpen: state.workflowPanelOpen,
projectId: state.projectId,
projectFolder: state.projectFolder,
autoPatch: state.autoPatch,
autoPatchNewNodes: state.autoPatchNewNodes,
autoSwitchTrusted: state.autoSwitchTrusted,
autoHookSave: state.autoHookSave,
kind: state.kind,
media: state.media,
name: state.name,
modelSelection: state.modelSelection,
detectedModelTag: state.detectedModelTag,
useCustomModel: state.useCustomModel,
customModelText: state.customModelText,
modelUpper: state.modelUpper,
pendingProjectName: state.pendingProjectName,
pendingSelectProjectId: state.pendingSelectProjectId,
lastRelDir: state.lastRelDir,
lastPrefix: state.lastPrefix,
pathTemplate: state.pathTemplate,
showMoreModels: state.showMoreModels,
};
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
} catch (_) {}
}
+11
View File
@@ -0,0 +1,11 @@
declare module "../../scripts/app.js" {
export const app: any;
}
declare module "../../../scripts/app.js" {
export const app: any;
}
interface Window {
MJR_DEBUG?: boolean;
}
+2391
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"lib": ["DOM", "ES2022"],
"rootDir": "src",
"outDir": "js",
"declaration": true,
"declarationMap": false,
"sourceMap": true,
"removeComments": false,
"noCheck": true,
"noEmitOnError": false,
"strict": false,
"noImplicitAny": false,
"useUnknownInCatchVariables": false,
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/**/*.ts"]
}