Files
wehub-resource-sync adf0d17497
publish / version_or_publish (push) Has been cancelled
storybook-build / changes (push) Has been cancelled
storybook-build / :storybook-build (push) Has been cancelled
Sync Gradio Skills to Hugging Face / sync-skills (push) Has been cancelled
functional / changes (push) Has been cancelled
functional / build-frontend (push) Has been cancelled
functional / functional-test-SSR=false (push) Has been cancelled
functional / functional-reload (push) Has been cancelled
js / changes (push) Has been cancelled
js / js-test (push) Has been cancelled
docs-build / changes (push) Has been cancelled
docs-build / docs-build (push) Has been cancelled
docs-build / website-build (push) Has been cancelled
functional / functional-test-SSR=true (push) Has been cancelled
hygiene / hygiene-test (push) Has been cancelled
python / changes (push) Has been cancelled
python / build (push) Has been cancelled
python / test-ubuntu-latest-flaky (push) Has been cancelled
python / test-ubuntu-latest-not-flaky (push) Has been cancelled
python / test-windows-latest-flaky (push) Has been cancelled
python / test-windows-latest-not-flaky (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:17:32 +08:00

1919 lines
45 KiB
Svelte

<script lang="ts">
import { TASK_SCHEMAS } from "./node-library";
import {
fetchSpaceApi,
is_zero_gpu_space,
normalize_space_id,
normalizeOperatorPorts
} from "./space-api";
import { MODALITIES } from "./workflow-modalities";
import type { ModalityConfig, SubTab } from "./workflow-modalities";
import SearchIcon from "./icons/SearchIcon.svelte";
import CloseIcon from "./icons/CloseIcon.svelte";
import OpenLinkIcon from "./icons/OpenLinkIcon.svelte";
import ImageIcon from "./icons/ImageIcon.svelte";
import AudioIcon from "./icons/AudioIcon.svelte";
import VideoIcon from "./icons/VideoIcon.svelte";
import Model3DIcon from "./icons/Model3DIcon.svelte";
import TextIcon from "./icons/TextIcon.svelte";
import DatasetIcon from "./icons/DatasetIcon.svelte";
import TaskIcon from "./icons/tasks/TaskIcon.svelte";
type ResultType = "space" | "model" | "dataset";
interface SpaceResult {
id: string;
title: string;
description: string;
likes: number;
type: ResultType;
pipeline_tag?: string;
zero_gpu?: boolean;
curated?: boolean;
thumbnail?: string;
}
interface Props {
mode: "create" | "update";
modality: ModalityConfig;
nodeId?: string;
server?: Record<string, any>;
anchorX?: number;
anchorY?: number;
initialSubtab?: string;
oncreate: (template: any) => void;
onupdate: (nodeId: string, template: any) => void;
onclose: () => void;
oncleared?: () => void;
onerror?: (message: string) => void;
}
let {
mode,
modality,
nodeId,
server = {},
anchorX = undefined,
anchorY = undefined,
initialSubtab = undefined,
oncreate,
onupdate,
onclose,
oncleared,
onerror
}: Props = $props();
const is_all = $derived(modality.key === "all");
const is_dataset = $derived(modality.key === "data");
const search_placeholder = $derived.by(() => {
if (is_dataset) return "Search datasets, paste a Hub URL...";
if (is_all) return "Search spaces & models, paste a Hub URL...";
return `Search ${modality.label.toLowerCase()} spaces & models, paste a Hub URL...`;
});
let available_tasks = $state<Set<string> | null>(null);
$effect(() => {
const m = modality.key;
available_tasks = null;
if (m === "data") return;
if (!server?.curated_modality_tasks) return;
void server
.curated_modality_tasks([m === "all" ? "" : m])
.then((raw: any) => {
try {
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
available_tasks = new Set(Array.isArray(parsed) ? parsed : []);
} catch {
available_tasks = new Set();
}
});
});
function subtab_available(st: SubTab): boolean {
if (available_tasks === null) return true;
if (st.key === "all") return true;
if (st.pipelineTag && available_tasks.has(st.pipelineTag)) return true;
if (st.spaceCategory && available_tasks.has(st.spaceCategory)) return true;
return false;
}
const sidebar_groups = $derived.by(() => {
if (is_dataset) return [];
available_tasks;
const mods = is_all ? MODALITIES : [modality];
return mods
.map((m) => ({
modality: m,
subtabs: m.subtabs.filter(
(st) => (!is_all || st.key !== "all") && subtab_available(st)
)
}))
.filter((g) => g.subtabs.length > 0);
});
const all_visible_subtabs = $derived.by(() => {
const flat = sidebar_groups.flatMap((g) => g.subtabs);
return is_all ? [modality.subtabs[0], ...flat] : flat;
});
let active_subtab = $state<SubTab>(
(initialSubtab && modality.subtabs.find((s) => s.key === initialSubtab)) ||
modality.subtabs[0]
);
let active_task_modality = $state<string | null>(null);
$effect(() => {
if (all_visible_subtabs.length === 0) return;
if (!all_visible_subtabs.some((st) => st.key === active_subtab.key)) {
active_subtab = all_visible_subtabs[0];
active_task_modality = null;
}
});
function select_task(st: SubTab, mod_key: string | null): void {
active_subtab = st;
active_task_modality = mod_key;
}
const AVATAR_COLORS = [
"#f97316",
"#8b5cf6",
"#3b82f6",
"#10b981",
"#ec4899",
"#f59e0b",
"#06b6d4",
"#84cc16"
];
function avatar_color(id: string): string {
let h = 5381;
for (let i = 0; i < id.length; i++) h = ((h << 5) + h) ^ id.charCodeAt(i);
return AVATAR_COLORS[Math.abs(h) % AVATAR_COLORS.length];
}
function avatar_initial(id: string): string {
return (id.split("/").pop() || id)[0].toUpperCase();
}
const SPACE_IMAGES: Record<string, string> = {};
let search_query = $state("");
let space_results = $state<SpaceResult[]>([]);
let model_results = $state<SpaceResult[]>([]);
let dataset_results = $state<SpaceResult[]>([]);
let loading = $state(false);
let zero_gpu_only = $state(false);
let loading_space_id = $state<string | null>(null);
const has_results = $derived(
is_dataset
? dataset_results.length > 0
: space_results.length + model_results.length > 0
);
let search_timeout: ReturnType<typeof setTimeout> | null = null;
let resolve_timeout: ReturnType<typeof setTimeout> | null = null;
let fetch_token = 0;
function parse_space(s: any): SpaceResult {
const desc = s.cardData?.short_description || s.ai_short_description || "";
return {
id: s.id,
title: s.cardData?.title || s.title || s.id.split("/").pop() || s.id,
description: desc,
likes: s.likes ?? 0,
type: "space",
pipeline_tag: s.cardData?.pipeline_tag || s.pipeline_tag || undefined,
zero_gpu: is_zero_gpu_space(s),
curated: s._curated === true,
thumbnail: s._thumbnail || undefined
};
}
function parse_model(m: any): SpaceResult {
return {
id: m.id,
title: m.id.split("/").pop() || m.id,
description: m.pipeline_tag || "",
likes: m.likes ?? 0,
type: "model",
pipeline_tag: m.pipeline_tag,
curated: m._curated === true,
thumbnail: m._thumbnail || undefined
};
}
async function fetch_results() {
if (is_dataset) {
await fetch_datasets(search_query);
return;
}
const token = ++fetch_token;
loading = true;
try {
const space_tag =
active_subtab.spaceCategory ?? active_subtab.pipelineTag;
const model_tag = active_subtab.pipelineTag;
const kind = search_query.length >= 2 ? "search" : "trending";
const query = search_query.length >= 2 ? search_query : "";
const backend_modality = is_all
? (active_task_modality ?? "")
: modality.key;
const calls: Promise<any>[] = [];
if (server?.search_spaces) {
calls.push(
server.search_spaces([
kind,
query,
space_tag,
backend_modality,
zero_gpu_only
])
);
} else {
calls.push(Promise.resolve("[]"));
}
if (server?.search_models) {
calls.push(
server.search_models([kind, query, model_tag, backend_modality])
);
} else {
calls.push(Promise.resolve("[]"));
}
const [rawSpaces, rawModels] = await Promise.all(calls);
if (token !== fetch_token) return;
const spaceData =
typeof rawSpaces === "string" ? JSON.parse(rawSpaces) : rawSpaces;
const modelData =
typeof rawModels === "string" ? JSON.parse(rawModels) : rawModels;
const spaces = Array.isArray(spaceData) ? spaceData.map(parse_space) : [];
const models = Array.isArray(modelData)
? modelData
.filter((m: any) => m.pipeline_tag && TASK_SCHEMAS[m.pipeline_tag])
.map(parse_model)
: [];
if (token !== fetch_token) return;
space_results = spaces.slice(0, 24);
model_results = models.slice(0, 20);
} catch {
if (token === fetch_token) {
space_results = [];
model_results = [];
}
} finally {
if (token === fetch_token) loading = false;
}
}
async function fetch_datasets(query: string) {
if (!server?.search_datasets) {
dataset_results = [];
loading = false;
return;
}
const token = ++fetch_token;
loading = true;
try {
const raw = await server.search_datasets([query]);
if (token !== fetch_token) return;
const data = typeof raw === "string" ? JSON.parse(raw) : raw;
if (!Array.isArray(data)) {
dataset_results = [];
return;
}
dataset_results = data.slice(0, 20).map((d: any) => ({
id: d.id,
title: d.id.split("/").pop() || d.id,
description: d.description || d.cardData?.summary || "",
likes: d.likes ?? 0,
type: "dataset" as const,
pipeline_tag: undefined
}));
} catch {
if (token === fetch_token) dataset_results = [];
} finally {
if (token === fetch_token) loading = false;
}
}
$effect(() => {
active_subtab;
active_task_modality;
is_dataset;
modality.key;
zero_gpu_only;
void fetch_results();
});
function handle_search_input(e: Event) {
search_query = (e.currentTarget as HTMLInputElement).value;
if (search_timeout) clearTimeout(search_timeout);
if (resolve_timeout) clearTimeout(resolve_timeout);
if (!is_dataset && looks_like_repo(search_query)) {
resolve_timeout = setTimeout(
() => void resolve_pinned(search_query),
120
);
} else {
pinned_result = null;
pinned_error = null;
pinned_loading = false;
pinned_token++;
}
loading = true;
const fetcher = is_dataset
? () => fetch_datasets(search_query)
: () => fetch_results();
search_timeout = setTimeout(() => void fetcher(), 150);
}
function select_model(item: SpaceResult) {
const schema = item.pipeline_tag ? TASK_SCHEMAS[item.pipeline_tag] : null;
const inputHints = active_subtab.inputs;
const outputHints = active_subtab.outputs;
const template = {
label: item.id.split("/").pop() || item.id,
kind: "transform" as const,
source: "model" as const,
model_id: item.id,
pipeline_tag: item.pipeline_tag,
inputs: normalizeOperatorPorts(
modality,
schema?.inputs ?? [],
inputHints
),
outputs: normalizeOperatorPorts(
modality,
schema?.outputs ?? [],
outputHints
),
width: 280,
height: 90
};
if (mode === "update" && nodeId) {
onupdate(nodeId, template);
} else {
oncreate(template);
}
onclose();
}
async function select_space(space: SpaceResult) {
if (loading_space_id) return;
loading_space_id = space.id;
try {
const api_info = await fetchSpaceApi(space.id);
const inputHints = active_subtab.inputs;
const outputHints = active_subtab.outputs;
const template = {
label: space.title || space.id.split("/").pop() || space.id,
kind: "transform" as const,
source: "space" as const,
space_id: space.id,
pipeline_tag: space.pipeline_tag,
endpoint: api_info.endpoint,
endpoints: api_info.endpoints,
inputs: normalizeOperatorPorts(modality, api_info.inputs, inputHints),
outputs: normalizeOperatorPorts(
modality,
api_info.outputs,
outputHints
),
width: api_info.width,
height: 90
};
if (mode === "update" && nodeId) {
onupdate(nodeId, template);
} else {
oncreate(template);
}
onclose();
} catch (err) {
loading_space_id = null;
const msg = err instanceof Error ? err.message : "Couldn't load space";
pinned_error = msg;
}
}
function feature_to_port_type(feature: any): string {
const t = feature?.type;
if (!t) return "json";
if (t._type === "Image" || t.dtype === "image") return "image";
if (t._type === "Audio" || t.dtype === "audio") return "audio";
if (t._type === "Video" || t.dtype === "video") return "video";
if (t._type === "ClassLabel") return "text";
if (t._type === "Translation" || t._type === "TranslationVariableLanguages")
return "text";
if (t._type === "Value") {
if (t.dtype === "string") return "text";
if (t.dtype?.includes("int") || t.dtype?.includes("float"))
return "number";
if (t.dtype === "bool") return "boolean";
}
return "json";
}
async function select_dataset(dataset: SpaceResult) {
loading_space_id = dataset.id;
let outputs: { id: string; label: string; type: string }[] = [
{ id: "out_0", label: "Data", type: "json" }
];
let config = "default";
let split = "train";
try {
if (server?.get_dataset_schema) {
const raw = await server.get_dataset_schema([dataset.id]);
const schema = typeof raw === "string" ? JSON.parse(raw) : raw;
if (
!schema.error &&
Array.isArray(schema.features) &&
schema.features.length > 0
) {
config = schema.config ?? "default";
split = schema.split ?? "train";
const ports = schema.features.map((f: any, i: number) => ({
id: `out_${i}`,
label: f.name,
type: feature_to_port_type(f)
}));
if (ports.length > 0) outputs = ports;
}
}
} catch {
} finally {
loading_space_id = null;
}
oncreate({
label: dataset.title || dataset.id.split("/").pop() || dataset.id,
kind: "transform" as const,
source: "dataset" as const,
dataset_id: dataset.id,
dataset_config: config,
dataset_split: split,
// Single scalar input: row index. Inline-editable when not wired
// (the standard `.port-inline-config` block handles the widget);
// accepts upstream numbers/fns when wired.
inputs: [
{
id: "row_index",
label: "Row",
type: "number" as const,
default_value: 0
}
],
outputs,
width: 240,
height: 90
});
onclose();
}
function hub_url(item: SpaceResult): string {
if (item.type === "dataset")
return `https://huggingface.co/datasets/${item.id}`;
if (item.type === "model") return `https://huggingface.co/${item.id}`;
return `https://huggingface.co/spaces/${item.id}`;
}
function handle_row_click(item: SpaceResult) {
if (item.type === "dataset") {
void select_dataset(item);
} else if (item.type === "model") {
select_model(item);
} else {
select_space(item);
}
}
let pinned_result = $state<SpaceResult | null>(null);
let pinned_loading = $state(false);
let pinned_error = $state<string | null>(null);
let pinned_token = 0;
function looks_like_repo(input: string): boolean {
return normalize_space_id(input) !== null;
}
async function resolve_pinned(input: string) {
if (!server?.resolve_repo) return;
const myToken = ++pinned_token;
pinned_loading = true;
pinned_error = null;
pinned_result = null;
try {
const raw = await server.resolve_repo([input]);
if (myToken !== pinned_token) return;
const data = typeof raw === "string" ? JSON.parse(raw) : raw;
if (data?.error) {
pinned_error = data.error === "not_found" ? "Not found" : null;
return;
}
const kind: ResultType =
data.kind === "model"
? "model"
: data.kind === "dataset"
? "dataset"
: "space";
const rec = data.record ?? {};
const desc =
rec.cardData?.short_description || rec.ai_short_description || "";
let curated = false;
if (server?.is_curated) {
try {
const cRaw = await server.is_curated([data.id, kind]);
const cData = typeof cRaw === "string" ? JSON.parse(cRaw) : cRaw;
curated = cData?.curated === true && cData?.status === "ok";
} catch {
/* noop */
}
if (myToken !== pinned_token) return;
}
pinned_result = {
id: data.id,
title:
rec.cardData?.title ||
rec.title ||
data.id.split("/").pop() ||
data.id,
description: desc,
likes: rec.likes ?? 0,
type: kind,
pipeline_tag:
rec.cardData?.pipeline_tag || rec.pipeline_tag || undefined,
zero_gpu: kind === "space" ? is_zero_gpu_space(rec) : undefined,
curated
};
} catch {
if (myToken === pinned_token) pinned_error = null;
} finally {
if (myToken === pinned_token) pinned_loading = false;
}
}
function format_likes(n: number): string {
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
return String(n);
}
</script>
{#snippet modality_icon(key: string)}
{#if key === "image"}
<ImageIcon />
{:else if key === "audio"}
<AudioIcon />
{:else if key === "video"}
<VideoIcon />
{:else if key === "3d"}
<Model3DIcon />
{:else if key === "text"}
<TextIcon />
{:else if key === "data"}
<DatasetIcon />
{/if}
{/snippet}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="picker-overlay" onclick={onclose}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="picker-panel"
class:picker-panel-anchored={anchorX !== undefined}
style={anchorX !== undefined
? `left:${anchorX}px; top:${anchorY ?? 0}px;`
: ""}
onclick={(e) => e.stopPropagation()}
onwheel={(e) => e.stopPropagation()}
>
{#if is_dataset}
<div class="picker-filter-pill" role="status">
<span class="picker-filter-label">Filter</span>
<span class="picker-filter-modality">
<span class="picker-filter-icon">
<DatasetIcon />
</span>
{modality.label}
</span>
<button
class="picker-filter-close"
onclick={() => (oncleared ? oncleared() : onclose())}
aria-label="Clear modality filter"
title="Clear filter — show all spaces & models"
>
<CloseIcon />
</button>
</div>
{/if}
<div class="picker-search-row">
<div class="picker-search-wrap">
<span class="search-icon">
<SearchIcon />
</span>
<input
class="picker-search"
type="text"
placeholder={search_placeholder}
value={search_query}
oninput={handle_search_input}
autofocus
/>
{#if !is_dataset}
<button
class="picker-zerogpu-toggle"
class:active={zero_gpu_only}
onclick={() => (zero_gpu_only = !zero_gpu_only)}
aria-pressed={zero_gpu_only}
title={zero_gpu_only
? "Showing only ZeroGPU spaces"
: "Show only ZeroGPU spaces"}
>
⚡ ZeroGPU
</button>
{/if}
<button
class="picker-close-inline"
onclick={onclose}
aria-label="Close picker"
title="Close"
>
<CloseIcon />
</button>
</div>
</div>
<div class="picker-body">
{#if !is_dataset}
<div class="picker-sidebar">
{#if is_all}
<div class="picker-sidebar-heading">
<span>All tasks</span>
</div>
<button
class="picker-task-item"
class:active={active_subtab.key === "all"}
onclick={() => select_task(modality.subtabs[0], null)}
>
<span class="picker-task-icon">
<TaskIcon pipeline="all" />
</span>
<span class="picker-task-label">All</span>
</button>
{#each sidebar_groups as group (group.modality.key)}
<div class="picker-sidebar-group-heading">
<span class="picker-sidebar-icon">
{@render modality_icon(group.modality.key)}
</span>
<span>{group.modality.label}</span>
</div>
{#each group.subtabs as st (st.key)}
<button
class="picker-task-item"
class:active={active_subtab.key === st.key}
onclick={() => select_task(st, group.modality.key)}
>
<span class="picker-task-icon">
<TaskIcon pipeline={st.pipelineTag || st.key} />
</span>
<span class="picker-task-label">{st.label}</span>
</button>
{/each}
{/each}
{:else}
<div class="picker-sidebar-heading">
<span class="picker-sidebar-icon">
{@render modality_icon(modality.key)}
</span>
<span>{modality.label} tasks</span>
</div>
<button
class="picker-sidebar-clear"
onclick={() => (oncleared ? oncleared() : onclose())}
aria-label="Clear modality filter"
title="Show all spaces & models"
>
← All tasks
</button>
{#each sidebar_groups[0]?.subtabs ?? [] as st (st.key)}
<button
class="picker-task-item"
class:active={active_subtab.key === st.key}
onclick={() => select_task(st, null)}
>
<span class="picker-task-icon">
<TaskIcon pipeline={st.pipelineTag || st.key} />
</span>
<span class="picker-task-label">{st.label}</span>
</button>
{/each}
{/if}
</div>
{/if}
<div class="picker-results" class:picker-results-stale={loading}>
{#if pinned_loading}
<div class="picker-pinned picker-pinned-loading">
<span class="spinner small"></span>
<span>Resolving repo…</span>
</div>
{:else if pinned_error && !pinned_result}
<div class="picker-pinned picker-pinned-error">{pinned_error}</div>
{:else if pinned_result}
{@const pinned = pinned_result}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="space-row pinned"
class:loading={loading_space_id === pinned.id}
class:uncurated={!pinned.curated}
onclick={() => handle_row_click(pinned)}
role="button"
tabindex="0"
title={pinned.curated
? `Use this exact ${pinned.type}`
: `Use this exact ${pinned.type} not in our verified catalog`}
>
<div
class="space-avatar"
style="background:linear-gradient(135deg, {avatar_color(
pinned.id
)}, {avatar_color(pinned.id + '_2')})"
>
{avatar_initial(pinned.id)}
</div>
<div class="space-row-left">
<div class="space-name-row">
<span class="space-name"
>{pinned.title ||
(pinned.id.split("/").pop() ?? pinned.id)}</span
>
<span class="type-chip type-chip-{pinned.type}"
>{pinned.type === "model" ? "Model" : "Space"}</span
>
{#if pinned.curated}
<span
class="verified-chip"
title="Verified by the workflow team — checked daily"
>✓ Verified</span
>
{:else}
<span
class="uncurated-chip"
title="Not in our verified catalog — may not work or may be down"
>Unverified</span
>
{/if}
{#if pinned.type === "space" && pinned.zero_gpu}
<span class="space-badge space-badge-zerogpu">⚡ ZeroGPU</span
>
{/if}
{#if pinned.pipeline_tag}
<span class="space-badge">{pinned.pipeline_tag}</span>
{/if}
</div>
<div class="space-desc">
{#if pinned.curated}
Use this exact {pinned.type}
{#if pinned.description}
{pinned.description}{/if}
{:else}
Not in our verified catalog — may not work. {pinned.description ||
""}
{/if}
</div>
</div>
<div class="space-row-right">
{#if loading_space_id === pinned.id}
<span class="spinner small"></span>
{/if}
<a
class="space-row-link"
href={hub_url(pinned)}
target="_blank"
rel="noopener noreferrer"
title="Open on HuggingFace"
aria-label="Open on HuggingFace"
onclick={(e) => e.stopPropagation()}
onmousedown={(e) => e.stopPropagation()}
>
<OpenLinkIcon />
</a>
</div>
</div>
{#if pinned_error}
<div class="picker-pinned picker-pinned-error">{pinned_error}</div>
{/if}
{/if}
{#if loading && !has_results}
<div class="picker-loading">
<span class="spinner"></span>
</div>
{:else if !has_results && !pinned_result}
<div class="picker-empty">
{search_query.length >= 2
? "Nothing found"
: is_all
? "No results found"
: `No ${modality.label.toLowerCase()} results found`}
</div>
{:else if is_dataset}
{#each dataset_results as dataset (dataset.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="space-row"
class:loading={loading_space_id === dataset.id}
onclick={() => handle_row_click(dataset)}
role="button"
tabindex="0"
>
<div
class="space-avatar"
style="background:linear-gradient(135deg, {avatar_color(
dataset.id
)}, {avatar_color(dataset.id + '_2')})"
>
{avatar_initial(dataset.id)}
</div>
<div class="space-row-left">
<div class="space-name-row">
<span class="space-name"
>{dataset.title ||
(dataset.id.split("/").pop() ?? dataset.id)}</span
>
</div>
{#if dataset.description}
<div class="space-desc">{dataset.description}</div>
{/if}
</div>
<div class="space-row-right">
{#if loading_space_id === dataset.id}
<span class="spinner small"></span>
{:else if dataset.likes > 0}
<span class="space-likes"
>{format_likes(dataset.likes)}</span
>
{/if}
<a
class="space-row-link"
href={hub_url(dataset)}
target="_blank"
rel="noopener noreferrer"
title="Open on HuggingFace"
aria-label="Open on HuggingFace"
onclick={(e) => e.stopPropagation()}
onmousedown={(e) => e.stopPropagation()}
>
<OpenLinkIcon />
</a>
</div>
</div>
{/each}
{:else}
{#if space_results.length > 0}
<div class="picker-section-header">Curated Spaces</div>
<div class="picker-space-grid">
{#each space_results as space (space.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="space-card"
class:loading={loading_space_id === space.id}
onclick={() => handle_row_click(space)}
role="button"
tabindex="0"
title={space.title || space.id}
>
<div class="space-card-thumb">
{#if space.thumbnail || SPACE_IMAGES[space.id]}
<img
src={space.thumbnail || SPACE_IMAGES[space.id]}
alt={space.title || space.id}
loading="lazy"
/>
{:else}
<div
class="space-card-avatar"
style="background:linear-gradient(135deg, {avatar_color(
space.id
)}, {avatar_color(space.id + '_2')})"
>
{avatar_initial(space.id)}
</div>
{/if}
{#if space.zero_gpu}
<span
class="space-card-zerogpu"
title="ZeroGPU — always running">⚡ ZeroGPU</span
>
{/if}
{#if loading_space_id === space.id}
<div class="space-card-loading">
<span class="spinner"></span>
</div>
{/if}
</div>
<div class="space-card-body">
<div class="space-card-title">
{space.title || (space.id.split("/").pop() ?? space.id)}
</div>
<div class="space-card-meta">
{#if space.likes > 0}
<span class="space-likes"
>{format_likes(space.likes)}</span
>
{/if}
{#if space.pipeline_tag}
<span class="space-card-tag">{space.pipeline_tag}</span>
{/if}
</div>
</div>
</div>
{/each}
</div>
{/if}
{#if model_results.length > 0}
<div class="picker-section-header">Curated Models</div>
<div class="picker-space-grid">
{#each model_results as model (model.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="space-card"
onclick={() => handle_row_click(model)}
role="button"
tabindex="0"
title={model.id}
>
<div class="space-card-thumb">
{#if model.thumbnail || SPACE_IMAGES[model.id]}
<img
src={model.thumbnail || SPACE_IMAGES[model.id]}
alt={model.title || model.id}
loading="lazy"
/>
{:else}
<div
class="space-card-avatar"
style="background:linear-gradient(135deg, {avatar_color(
model.id
)}, {avatar_color(model.id + '_2')})"
>
{avatar_initial(model.id)}
</div>
{/if}
</div>
<div class="space-card-body">
<div class="space-card-title">
{model.title || (model.id.split("/").pop() ?? model.id)}
</div>
<div class="space-card-meta">
<span class="space-card-owner"
>{model.id.split("/")[0]}</span
>
{#if model.likes > 0}
<span class="space-likes"
>{format_likes(model.likes)}</span
>
{/if}
{#if model.pipeline_tag}
<span class="space-card-tag">{model.pipeline_tag}</span>
{/if}
</div>
</div>
</div>
{/each}
</div>
{/if}
{/if}
</div>
</div>
</div>
</div>
<style>
/* Reuse toast-in animation from WorkflowCanvas.css */
@keyframes wf-toast-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.picker-overlay {
position: absolute;
inset: 0;
z-index: 50;
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
/* Compensate for the bottom bar (~60px) so the panel is centered in
* the visible canvas area, not the raw viewport. Extra few px of
* bottom padding keeps the optical center balanced against the top
* toolbar. */
padding-bottom: 72px;
padding-top: 8px;
box-sizing: border-box;
}
.picker-panel {
pointer-events: auto;
width: min(940px, calc(100vw - 64px));
/* Lock the panel to a fixed height so switching tasks doesn't make
* the modal jump up and down. The results list scrolls inside; on
* short viewports we cap to leave room for the toolbar and bottom
* bar. */
height: min(720px, calc(100vh - 160px));
background: #13141c;
border: 1px solid #2a2b36;
border-radius: 16px;
box-shadow:
0 20px 60px rgba(0, 0, 0, 0.6),
0 0 0 1px rgba(255, 255, 255, 0.04);
display: flex;
flex-direction: column;
overflow: hidden;
animation: wf-toast-in 0.12s ease-out;
}
.picker-panel-anchored {
position: absolute;
}
.picker-close-inline {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
width: 22px;
height: 22px;
border: none;
border-radius: 50%;
background: transparent;
color: #5c5e6a;
font-size: 16px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 0;
transition:
background 0.15s,
color 0.15s;
}
.picker-close-inline:hover {
background: #1e1f2a;
color: #a0a2ae;
}
:global(body:not(.dark)) .picker-close-inline {
color: #8b8d98;
}
:global(body:not(.dark)) .picker-close-inline:hover {
background: rgba(0, 0, 0, 0.06);
color: #3e4050;
}
.picker-zerogpu-toggle {
position: absolute;
right: 38px;
top: 50%;
transform: translateY(-50%);
display: inline-flex;
align-items: center;
gap: 4px;
height: 22px;
padding: 0 8px;
border: 1px solid rgba(249, 115, 22, 0.4);
border-radius: 11px;
background: transparent;
color: rgba(249, 115, 22, 0.85);
font-family: "Manrope", sans-serif;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.02em;
cursor: pointer;
transition:
background 0.15s,
color 0.15s,
border-color 0.15s;
}
.picker-zerogpu-toggle:hover {
background: rgba(249, 115, 22, 0.12);
}
.picker-zerogpu-toggle.active {
background: rgba(249, 115, 22, 0.85);
border-color: rgba(249, 115, 22, 0.85);
color: #fff;
}
.picker-filter-pill {
display: flex;
align-items: center;
gap: 8px;
margin: 12px 14px 0;
padding: 6px 8px 6px 12px;
border-radius: 28px;
background: rgba(249, 115, 22, 0.12);
box-shadow: inset 0 0 0 1px rgba(249, 115, 22, 0.32);
font-family: "Manrope", sans-serif;
flex-shrink: 0;
}
.picker-filter-label {
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #f97316;
opacity: 0.75;
}
.picker-filter-modality {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 700;
color: #f97316;
letter-spacing: -0.01em;
}
.picker-filter-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
}
.picker-filter-close {
margin-left: auto;
width: 22px;
height: 22px;
border: none;
border-radius: 50%;
background: transparent;
color: rgba(249, 115, 22, 0.7);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
transition:
background 0.12s,
color 0.12s;
}
.picker-filter-close:hover {
background: rgba(249, 115, 22, 0.18);
color: #f97316;
}
:global(body:not(.dark)) .picker-filter-pill {
background: rgba(249, 115, 22, 0.1);
box-shadow: inset 0 0 0 1px rgba(249, 115, 22, 0.3);
}
:global(body:not(.dark)) .picker-filter-label,
:global(body:not(.dark)) .picker-filter-modality {
color: #d65500;
}
:global(body:not(.dark)) .picker-filter-close {
color: rgba(214, 85, 0, 0.7);
}
:global(body:not(.dark)) .picker-filter-close:hover {
background: rgba(214, 85, 0, 0.14);
color: #d65500;
}
.picker-search-row {
padding: 14px 16px 10px;
flex-shrink: 0;
}
.picker-search-wrap {
position: relative;
display: flex;
align-items: center;
}
.search-icon {
position: absolute;
left: 13px;
display: inline-flex;
color: #4a4d57;
pointer-events: none;
flex-shrink: 0;
}
.picker-search {
flex: 1;
min-width: 0;
padding: 11px 130px 11px 36px;
background: #0c0d10;
border: 1px solid #2a2b36;
border-radius: 10px;
font-family: "Manrope", sans-serif;
font-size: 14px;
color: #d5d6de;
outline: none;
transition:
border-color 0.15s,
box-shadow 0.15s;
}
.picker-search::placeholder {
color: #3e4050;
}
.picker-search:focus {
border-color: #f97316;
box-shadow: 0 0 0 3px rgba(249, 115, 22, 0.1);
}
.picker-body {
display: flex;
flex: 1;
min-height: 0;
}
.picker-sidebar {
width: 200px;
flex-shrink: 0;
overflow-y: auto;
border-right: 1px solid #2a2b36;
padding: 4px 8px 14px;
scrollbar-width: thin;
scrollbar-color: #2a2b36 transparent;
}
.picker-sidebar-heading,
.picker-sidebar-group-heading {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 8px 6px;
font-family: "Manrope", sans-serif;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #4a4d57;
}
.picker-sidebar-group-heading {
margin-top: 14px;
}
.picker-sidebar-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
}
.picker-sidebar-clear {
display: block;
width: calc(100% - 16px);
margin: 2px 8px 8px;
padding: 6px 10px;
border: 1px solid #2a2b36;
border-radius: 8px;
background: transparent;
color: #8b8d98;
font-family: "Manrope", sans-serif;
font-size: 11px;
font-weight: 600;
text-align: left;
cursor: pointer;
transition:
background 0.12s,
border-color 0.12s,
color 0.12s;
}
.picker-sidebar-clear:hover {
background: rgba(255, 255, 255, 0.04);
border-color: #3a3b48;
color: #d5d6de;
}
.picker-task-item {
width: 100%;
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
border: none;
border-radius: 8px;
background: transparent;
color: #8b8d98;
font-family: "Manrope", sans-serif;
font-size: 12px;
font-weight: 600;
letter-spacing: -0.01em;
text-align: left;
cursor: pointer;
transition:
background 0.12s,
color 0.12s;
}
.picker-task-item:hover {
background: rgba(255, 255, 255, 0.04);
color: #e8e9f0;
}
.picker-task-item.active {
background: rgba(249, 115, 22, 0.08);
color: #f97316;
}
.picker-task-icon {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 16px;
line-height: 1;
opacity: 0.85;
flex-shrink: 0;
}
.picker-task-item:hover .picker-task-icon,
.picker-task-item.active .picker-task-icon {
opacity: 1;
}
.picker-task-label {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
:global(body:not(.dark)) .picker-sidebar {
border-right-color: #e2e4ea;
scrollbar-color: #e2e4ea transparent;
}
:global(body:not(.dark)) .picker-sidebar-heading,
:global(body:not(.dark)) .picker-sidebar-group-heading {
color: #8b8d98;
}
:global(body:not(.dark)) .picker-sidebar-clear {
border-color: #e2e4ea;
color: #6b6e78;
}
:global(body:not(.dark)) .picker-sidebar-clear:hover {
background: rgba(0, 0, 0, 0.04);
border-color: #cdd0d8;
color: #1a1b25;
}
:global(body:not(.dark)) .picker-task-item {
color: #6b6e78;
}
:global(body:not(.dark)) .picker-task-item:hover {
background: rgba(0, 0, 0, 0.04);
color: #1a1b25;
}
:global(body:not(.dark)) .picker-task-item.active {
background: rgba(249, 115, 22, 0.1);
color: #d65500;
}
.type-chip {
font-family: "Manrope", sans-serif;
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 1px 6px;
border-radius: 8px;
flex-shrink: 0;
}
.type-chip-space {
background: rgba(249, 115, 22, 0.12);
color: #f97316;
}
.type-chip-model {
background: rgba(139, 92, 246, 0.14);
color: #8b5cf6;
}
:global(body:not(.dark)) .type-chip-space {
background: rgba(234, 88, 12, 0.1);
color: #d65500;
}
:global(body:not(.dark)) .type-chip-model {
background: rgba(124, 58, 237, 0.1);
color: #7c3aed;
}
.verified-chip {
font-family: "Manrope", sans-serif;
font-size: 10px;
font-weight: 700;
color: #10b981;
flex-shrink: 0;
}
:global(body:not(.dark)) .verified-chip {
color: #059669;
}
.uncurated-chip {
font-family: "Manrope", sans-serif;
font-size: 9px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 1px 6px;
border-radius: 8px;
background: rgba(245, 158, 11, 0.14);
color: #f59e0b;
flex-shrink: 0;
}
:global(body:not(.dark)) .uncurated-chip {
background: rgba(217, 119, 6, 0.1);
color: #b45309;
}
.space-row.pinned.uncurated {
background: rgba(245, 158, 11, 0.05);
border-left-color: #f59e0b;
}
:global(body:not(.dark)) .space-row.pinned.uncurated {
background: rgba(245, 158, 11, 0.04);
}
.picker-pinned {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
font-family: "Manrope", sans-serif;
font-size: 12px;
color: #8b8d98;
}
.picker-pinned-error {
color: #ef4444;
background: rgba(239, 68, 68, 0.08);
border-radius: 8px;
margin: 4px 0;
font-size: 12.5px;
}
.space-row.pinned {
background: rgba(249, 115, 22, 0.06);
}
:global(body:not(.dark)) .space-row.pinned {
background: rgba(249, 115, 22, 0.05);
}
.picker-results {
position: relative;
flex: 1;
min-width: 0;
overflow-y: auto;
padding: 6px 10px 12px;
scrollbar-width: thin;
scrollbar-color: #2a2b36 transparent;
transition: opacity 0.12s ease;
}
.picker-results-stale {
opacity: 0.55;
}
.picker-section-header {
font-family: "Manrope", sans-serif;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
color: #4a4d57;
padding: 10px 6px 6px;
}
:global(body:not(.dark)) .picker-section-header {
color: #8b8d98;
}
.picker-space-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
padding: 2px 4px 8px;
}
.space-card {
display: block;
min-height: 180px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.025);
border: 1px solid rgba(255, 255, 255, 0.05);
cursor: pointer;
overflow: hidden;
transition:
background 0.12s,
border-color 0.12s,
transform 0.12s;
}
.space-card:hover {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(249, 115, 22, 0.35);
transform: translateY(-1px);
}
.space-card.loading {
opacity: 0.6;
pointer-events: none;
}
.space-card-thumb {
position: relative;
display: block;
width: 100%;
height: 120px;
min-height: 120px;
max-height: 120px;
overflow: hidden;
background: #0c0d10;
flex-shrink: 0;
}
.space-card-thumb img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.space-card-avatar {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-family: "Manrope", sans-serif;
font-size: 48px;
font-weight: 800;
color: rgba(0, 0, 0, 0.55);
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.18);
}
.space-card-zerogpu {
position: absolute;
top: 6px;
right: 6px;
font-family: "Manrope", sans-serif;
font-size: 9px;
font-weight: 700;
padding: 2px 6px;
border-radius: 6px;
background: rgba(249, 115, 22, 0.85);
color: #fff;
letter-spacing: 0.02em;
}
.space-card-loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(12, 13, 16, 0.55);
}
.space-card-body {
padding: 8px 10px 10px;
min-width: 0;
}
.space-card-title {
display: flex;
align-items: center;
gap: 4px;
font-family: "Manrope", sans-serif;
font-size: 13px;
font-weight: 700;
color: #e8e9f0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.space-card-meta {
display: flex;
align-items: center;
gap: 6px;
margin-top: 3px;
font-family: "Manrope", sans-serif;
font-size: 10px;
color: #6b6e78;
white-space: nowrap;
overflow: hidden;
}
.space-card-tag {
color: #8b8d98;
text-overflow: ellipsis;
overflow: hidden;
}
:global(body:not(.dark)) .space-card {
background: #fafbfc;
border-color: #e8eaef;
}
:global(body:not(.dark)) .space-card:hover {
background: #f4f5f8;
border-color: rgba(249, 115, 22, 0.35);
}
:global(body:not(.dark)) .space-card-thumb {
background: #f0f1f5;
}
:global(body:not(.dark)) .space-card-title {
color: #1a1b25;
}
:global(body:not(.dark)) .space-card-meta {
color: #8b8d98;
}
:global(body:not(.dark)) .space-card-tag {
color: #6b6e78;
}
.space-card-owner {
color: #6b6e78;
flex-shrink: 0;
}
:global(body:not(.dark)) .space-card-owner {
color: #8b8d98;
}
.picker-loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.picker-empty {
text-align: center;
padding: 60px 24px;
font-family: "Manrope", sans-serif;
font-size: 13px;
color: #4a4d57;
}
.space-avatar {
width: 52px;
height: 52px;
border-radius: 12px;
font-size: 22px;
font-weight: 700;
color: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
text-shadow: 0 1px 2px rgba(255, 255, 255, 0.15);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.15);
}
.space-row {
display: flex;
align-items: center;
gap: 14px;
padding: 10px 12px;
border-radius: 10px;
cursor: pointer;
transition: background 0.12s;
}
.space-row:hover {
background: rgba(255, 255, 255, 0.04);
}
.space-row.loading {
opacity: 0.6;
pointer-events: none;
}
.space-row-left {
flex: 1;
min-width: 0;
}
.space-name-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.space-name {
font-family: "Manrope", sans-serif;
font-size: 14px;
font-weight: 700;
color: #e8e9f0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.space-badge {
display: inline-flex;
align-items: center;
padding: 2px 7px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.06);
color: #8b8d98;
font-family: "Manrope", sans-serif;
font-size: 10px;
font-weight: 600;
white-space: nowrap;
flex-shrink: 0;
}
.space-badge-zerogpu {
background: rgba(249, 115, 22, 0.14);
color: #f97316;
}
.space-badge-providers {
background: rgba(56, 189, 248, 0.14);
color: #38bdf8;
}
.space-desc {
font-family: "Manrope", sans-serif;
font-size: 12.5px;
color: #7a7d88;
margin-top: 3px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.35;
}
.space-row-right {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.space-likes {
font-family: "Manrope", sans-serif;
font-size: 11px;
font-weight: 600;
color: #4a4d57;
flex-shrink: 0;
}
.space-row-link {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 6px;
color: #4a4d57;
text-decoration: none;
opacity: 0;
flex-shrink: 0;
transition:
opacity 0.15s,
color 0.15s,
background 0.15s;
}
.space-row:hover .space-row-link,
.space-row:focus-within .space-row-link {
opacity: 1;
}
.space-row-link:hover {
color: #f97316;
background: rgba(249, 115, 22, 0.08);
}
.spinner {
display: inline-block;
width: 18px;
height: 18px;
border: 2px solid #2a2b36;
border-top-color: #f97316;
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
.spinner.small {
width: 14px;
height: 14px;
border-width: 1.5px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Light mode */
:global(body:not(.dark)) .picker-panel {
background: #ffffff;
border-color: #e2e4ea;
box-shadow:
0 12px 40px rgba(0, 0, 0, 0.12),
0 0 0 1px rgba(0, 0, 0, 0.04);
}
:global(body:not(.dark)) .picker-search {
background: #f8f9fb;
border-color: #e2e4ea;
color: #1a1b25;
}
:global(body:not(.dark)) .picker-search::placeholder {
color: #b0b2bc;
}
:global(body:not(.dark)) .picker-search:focus {
background: #ffffff;
}
:global(body:not(.dark)) .space-row:hover {
background: #f4f5f8;
}
:global(body:not(.dark)) .space-name {
color: #1a1b25;
}
:global(body:not(.dark)) .space-desc {
color: #6b6e78;
}
:global(body:not(.dark)) .space-badge {
background: #eef0f4;
color: #6b6e78;
}
:global(body:not(.dark)) .space-badge-zerogpu {
background: rgba(249, 115, 22, 0.12);
color: #c84a00;
}
:global(body:not(.dark)) .space-badge-providers {
background: rgba(56, 189, 248, 0.14);
color: #0369a1;
}
:global(body:not(.dark)) .space-likes {
color: #b0b2bc;
}
:global(body:not(.dark)) .picker-empty {
color: #b0b2bc;
}
:global(body:not(.dark)) .space-avatar {
color: rgba(0, 0, 0, 0.55);
}
</style>