Files
MajoorWaldi-ComfyUI-Majoor-…/ui/vue/components/workflows/WorkflowPickerDialog.vue
T
2026-06-09 10:28:25 +02:00

291 lines
11 KiB
Vue

<script setup>
import { computed, onBeforeUnmount, ref, watch } from "vue";
import { listWorkflows } from "../../../api/client.js";
import { t } from "../../../app/i18n.js";
import { workflowPickerState, closeWorkflowPicker } from "../../../features/workflows/workflowPickerState.js";
const query = ref("");
const loading = ref(false);
const error = ref("");
const workflows = ref([]);
const selectedPath = ref("");
let requestId = 0;
const sourceAsset = computed(() => workflowPickerState.sourceAsset || null);
const sourceWorkflow = computed(() => workflowPickerState.workflow || null);
const isAssetMode = computed(() => String(workflowPickerState.mode || "") === "asset");
const sourceName = computed(() =>
String(
sourceAsset.value?.filename ||
sourceAsset.value?.display_name ||
sourceWorkflow.value?.display_name ||
sourceWorkflow.value?.filename ||
sourceAsset.value?.filepath ||
sourceWorkflow.value?.filepath ||
"",
),
);
const tasks = computed(() => uniqueOption("task"));
const models = computed(() => uniqueOption("model_family"));
const runsOns = computed(() => uniqueOption("runs_on"));
const taskFilter = ref("");
const modelFilter = ref("");
const runsOnFilter = ref("");
function uniqueOption(key) {
const seen = new Set();
for (const workflow of workflows.value || []) {
const value = String(workflow?.[key] || "").trim();
if (value) seen.add(value);
}
return Array.from(seen).sort((a, b) => a.localeCompare(b));
}
const filteredWorkflows = computed(() => {
const q = query.value.trim().toLowerCase();
return (workflows.value || []).filter((workflow) => {
if (!isAssetMode.value) {
if (taskFilter.value && String(workflow?.task || "") !== taskFilter.value) return false;
if (modelFilter.value && String(workflow?.model_family || "") !== modelFilter.value) return false;
if (runsOnFilter.value && String(workflow?.runs_on || "") !== runsOnFilter.value) return false;
}
if (!q) return true;
const haystack = [
workflow?.display_name,
workflow?.filename,
workflow?.description,
workflow?.task,
workflow?.model_family,
workflow?.provider,
workflow?.subfolder,
workflow?.kind,
workflow?.filepath,
].join(" ").toLowerCase();
return haystack.includes(q);
});
});
const selectedWorkflow = computed(() =>
(workflows.value || []).find((workflow) => String(workflow?.filepath || "") === selectedPath.value) || null,
);
function workflowTitle(workflow) {
return String(workflow?.display_name || workflow?.name || workflow?.filename || "Workflow");
}
function workflowMeta(workflow) {
if (isAssetMode.value) {
return [workflow?.kind, workflow?.mtime ? new Date(Number(workflow.mtime) * 1000).toLocaleString() : ""]
.map((value) => String(value || "").trim())
.filter(Boolean)
.join(" / ");
}
return [workflow?.task, workflow?.model_family, workflow?.runs_on, workflow?.subfolder]
.map((value) => String(value || "").trim())
.filter(Boolean)
.join(" / ");
}
function workflowThumb(workflow) {
return String(
workflow?.thumbnail_url ||
workflow?.animated_thumbnail_url ||
workflow?.thumb_url ||
workflow?.preview_url ||
workflow?.graph_map_thumbnail_url ||
"",
).trim();
}
async function refreshWorkflows() {
const id = ++requestId;
loading.value = true;
error.value = "";
try {
if (isAssetMode.value) {
workflows.value = Array.isArray(workflowPickerState.items)
? workflowPickerState.items.filter((item) => String(item?.filepath || "").trim())
: [];
if (!selectedPath.value && workflows.value[0]?.filepath) {
selectedPath.value = String(workflows.value[0].filepath);
}
return;
}
const res = await listWorkflows({ q: "*", limit: 300, sort: "mtime" }, { timeoutMs: 20_000 });
if (id !== requestId) return;
if (!res?.ok) {
error.value = String(res?.error || "Failed to load workflows");
workflows.value = [];
return;
}
const list = Array.isArray(res?.data?.assets)
? res.data.assets
: Array.isArray(res?.data)
? res.data
: [];
workflows.value = list.filter((workflow) => String(workflow?.filepath || "").trim());
if (!selectedPath.value && workflows.value[0]?.filepath) {
selectedPath.value = String(workflows.value[0].filepath);
}
} catch (err) {
if (id === requestId) {
error.value = String(err?.message || err || "Failed to load workflows");
workflows.value = [];
}
} finally {
if (id === requestId) loading.value = false;
}
}
function applySelection() {
if (!selectedWorkflow.value) return;
closeWorkflowPicker(selectedWorkflow.value);
}
function cancel() {
closeWorkflowPicker(null);
}
function onKeydown(event) {
if (event.key === "Escape") {
event.preventDefault();
cancel();
}
}
watch(
() => workflowPickerState.open,
(open) => {
if (!open) {
try {
window.removeEventListener("keydown", onKeydown);
} catch {}
return;
}
selectedPath.value = "";
query.value = "";
taskFilter.value = "";
modelFilter.value = "";
runsOnFilter.value = "";
void refreshWorkflows();
try {
window.addEventListener("keydown", onKeydown);
} catch {}
},
);
onBeforeUnmount(() => {
try {
window.removeEventListener("keydown", onKeydown);
} catch {}
});
</script>
<template>
<Teleport to="body">
<div
v-if="workflowPickerState.open"
class="mjr-workflow-picker-backdrop"
@mousedown.self="cancel"
>
<section
class="mjr-workflow-picker-dialog"
:class="{ 'is-asset-mode': isAssetMode }"
role="dialog"
aria-modal="true"
>
<header class="mjr-workflow-picker-header">
<div class="mjr-workflow-picker-title">
<i class="pi pi-sitemap" />
<span>{{ workflowPickerState.title }}</span>
</div>
<button class="mjr-workflow-picker-icon-btn" type="button" @click="cancel">
<i class="pi pi-times" />
</button>
</header>
<div class="mjr-workflow-picker-source" v-if="sourceName">
<span>{{ isAssetMode ? t("tab.workflow", "Workflow") : t("dialog.workflowThumbnailSource", "Source") }}</span>
<strong>{{ sourceName }}</strong>
</div>
<div class="mjr-workflow-picker-toolbar">
<label class="mjr-workflow-picker-search">
<i class="pi pi-search" />
<input
v-model="query"
type="search"
:placeholder="t('search.workflows', 'Search workflows...')"
/>
</label>
<select v-if="!isAssetMode" v-model="taskFilter">
<option value="">{{ t("filter.task", "Task") }}</option>
<option v-for="item in tasks" :key="item" :value="item">{{ item }}</option>
</select>
<select v-if="!isAssetMode" v-model="modelFilter">
<option value="">{{ t("filter.model", "Model") }}</option>
<option v-for="item in models" :key="item" :value="item">{{ item }}</option>
</select>
<select v-if="!isAssetMode" v-model="runsOnFilter">
<option value="">{{ t("filter.runsOn", "Runs on") }}</option>
<option v-for="item in runsOns" :key="item" :value="item">{{ item }}</option>
</select>
</div>
<div class="mjr-workflow-picker-body">
<div v-if="loading" class="mjr-workflow-picker-empty">
{{ t("status.loading", "Loading...") }}
</div>
<div v-else-if="error" class="mjr-workflow-picker-empty is-error">
{{ error }}
</div>
<div v-else-if="!filteredWorkflows.length" class="mjr-workflow-picker-empty">
{{ t("toast.noWorkflowsFound", "No workflows found.") }}
</div>
<template v-else>
<button
v-for="workflow in filteredWorkflows"
:key="workflow.filepath"
type="button"
class="mjr-workflow-picker-card"
:class="{ 'is-selected': selectedPath === String(workflow.filepath || '') }"
@click="selectedPath = String(workflow.filepath || '')"
@dblclick="applySelection"
>
<div class="mjr-workflow-picker-thumb">
<img
v-if="workflowThumb(workflow)"
:src="workflowThumb(workflow)"
:alt="workflowTitle(workflow)"
loading="lazy"
/>
<i v-else class="pi pi-sitemap" />
</div>
<div class="mjr-workflow-picker-card-info">
<strong>{{ workflowTitle(workflow) }}</strong>
<span>{{ workflowMeta(workflow) }}</span>
<small>{{ workflow.filename }}</small>
</div>
</button>
</template>
</div>
<footer class="mjr-workflow-picker-footer">
<button type="button" class="mjr-workflow-picker-btn" @click="cancel">
{{ t("action.cancel", "Cancel") }}
</button>
<button
type="button"
class="mjr-workflow-picker-btn is-primary"
:disabled="!selectedWorkflow"
@click="applySelection"
>
{{ t("action.apply", "Apply") }}
</button>
</footer>
</section>
</div>
</Teleport>
</template>