Files
iknowyou/front/src/components/ToolList.svelte
2026-04-06 15:12:34 +02:00

347 lines
11 KiB
Svelte

<script>
import { onMount } from "svelte";
import { AlertTriangle } from "@lucide/svelte";
import Select from "./comps/Select.svelte";
import { INPUT_TYPES } from "@src/lib/vars";
import ToolIcon from "./comps/ToolIcon.svelte";
let tools = $state([]);
let config = $state({ tools: {}, profiles: {} });
let profileSummaries = $state([]);
let selectedProfile = $state("default");
let profileDetail = $state(null);
let loading = $state(true);
let profileLoading = $state(false);
let error = $state("");
let selectedInputType = $state("all");
const inputTypeOptions = ["all", ...INPUT_TYPES];
let profileOptions = $derived(profileSummaries.map((p) => p.name));
let activeSet = $derived(
profileDetail
? new Set(profileDetail.active_tools ?? [])
: new Set(tools.map((t) => t.name)),
);
let globalToolConf = $derived(config.tools ?? {});
let profileOverrides = $derived(profileDetail?.tools ?? {});
let toolsWithStatus = $derived(
tools.map((tool) => {
const isActive = activeSet.has(tool.name);
const effective = {
...(globalToolConf[tool.name] ?? {}),
...(profileOverrides[tool.name] ?? {}),
};
const missingConfig = (tool.config_fields ?? []).some((f) => {
if (!f.required) return false;
const v = effective[f.name];
return v === undefined || v === null || v === "";
});
const unavailable = tool.available === false;
return { ...tool, isActive, missingConfig, unavailable };
}),
);
let visibleTools = $derived(
selectedInputType === "all"
? toolsWithStatus
: toolsWithStatus.filter((t) =>
t.input_types.includes(selectedInputType),
),
);
let active = $derived(
visibleTools.filter(
(t) => t.isActive && !t.missingConfig && !t.unavailable,
),
);
let activeMissing = $derived(
visibleTools.filter((t) => t.isActive && t.missingConfig && !t.unavailable),
);
let activeUnavail = $derived(
visibleTools.filter((t) => t.isActive && t.unavailable),
);
let inactive = $derived(
visibleTools.filter(
(t) => !t.isActive && !t.missingConfig && !t.unavailable,
),
);
let inactiveMissing = $derived(
visibleTools.filter(
(t) => !t.isActive && t.missingConfig && !t.unavailable,
),
);
let inactiveUnavail = $derived(
visibleTools.filter((t) => !t.isActive && t.unavailable),
);
onMount(async () => {
try {
const [tr, cr, pr] = await Promise.all([
fetch("/api/tools"),
fetch("/api/config"),
fetch("/api/config/profiles"),
]);
if (!tr.ok) throw new Error(`HTTP ${tr.status}`);
if (!cr.ok) throw new Error(`HTTP ${cr.status}`);
if (!pr.ok) throw new Error(`HTTP ${pr.status}`);
tools = await tr.json();
config = await cr.json();
profileSummaries = await pr.json();
} catch (e) {
error = e.message;
} finally {
loading = false;
}
});
async function selectProfile(name) {
selectedProfile = name;
profileLoading = true;
try {
const res = await fetch(
`/api/config/profiles/${encodeURIComponent(name)}`,
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
profileDetail = await res.json();
} catch (e) {
error = e.message;
} finally {
profileLoading = false;
}
}
</script>
{#snippet toolCard(tool, missing)}
<div
class="card bg-base-200 group-hover:bg-base-300 transition-colors shadow-sm h-full
{missing ? 'border border-warning/40' : ''}
{tool.unavailable ? 'border border-error/40' : ''}"
>
<div class="card-body p-4 flex-row items-start gap-0">
<div
class="size-10 rounded-lg bg-base-300 group-hover:bg-base-200 transition-colors
flex items-center justify-center shrink-0 mr-3 mt-0.5"
>
<ToolIcon iconName={tool.icon} size={20} />
</div>
<div class="flex flex-col min-w-0 flex-1 gap-1.5">
<div class="flex items-center gap-2 flex-wrap">
<span class="font-bold text-sm leading-tight">{tool.name}</span>
{#if tool.unavailable}
<span class="badge badge-error badge-xs gap-1">
<AlertTriangle size={9} /> unavailable
</span>
{:else if missing}
<span class="badge badge-warning badge-xs gap-1">
<AlertTriangle size={9} /> config required
</span>
{/if}
</div>
{#if tool.unavailable && tool.unavailable_reason}
<p class="text-xs text-error/70 leading-relaxed">
{tool.unavailable_reason}
</p>
{:else if tool.description}
<p class="text-xs text-base-content/50 line-clamp-2 leading-relaxed">
{tool.description}
</p>
{/if}
<div class="flex flex-wrap gap-1">
{#each tool.input_types as t}
<span class="badge badge-xs badge-outline border-base-content/20">{t}</span>
{/each}
</div>
</div>
</div>
</div>
{/snippet}
{#if loading}
<div class="flex justify-center py-12">
<span class="loading loading-spinner loading-md"></span>
</div>
{:else if error}
<div class="alert alert-error gap-3"><AlertTriangle size={18} class="shrink-0" />{error}</div>
{:else}
<div class="flex flex-wrap items-center gap-x-6 gap-y-3 mb-6">
<div class="flex items-center gap-3">
<span
class="text-xs uppercase tracking-widest text-base-content/50 shrink-0"
>Profile</span
>
<Select
options={profileOptions}
selected={selectedProfile}
onselect={selectProfile}
/>
{#if profileLoading}
<span class="loading loading-spinner loading-xs opacity-40"></span>
{/if}
</div>
<div class="flex items-center gap-3">
<span
class="text-xs uppercase tracking-widest text-base-content/50 shrink-0"
>Input</span
>
<Select
options={inputTypeOptions}
selected={selectedInputType}
onselect={(val) => (selectedInputType = val)}
/>
</div>
</div>
{#if tools.length === 0}
<p class="text-base-content/40 text-sm text-center py-8">
No tools registered.
</p>
{:else}
<div class="flex flex-col gap-6">
{#if active.length > 0}
<section>
<div class="flex items-center gap-2 mb-3">
<span class="size-1.5 rounded-full bg-success shrink-0"></span>
<span class="text-xs uppercase tracking-widest text-base-content/50"
>Active</span
>
<span class="text-xs text-base-content/30">{active.length}</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
{#each active as tool}
<a href="/tools/{tool.name}" class="group">
{@render toolCard(tool, false)}
</a>
{/each}
</div>
</section>
{/if}
{#if activeMissing.length > 0}
<section>
<div class="flex items-center gap-2 mb-3">
<span class="size-1.5 rounded-full bg-warning shrink-0"></span>
<span class="text-xs uppercase tracking-widest text-base-content/50"
>Active - required config missing</span
>
<span class="text-xs text-base-content/30"
>{activeMissing.length}</span
>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
{#each activeMissing as tool}
<a href="/tools/{tool.name}" class="group">
{@render toolCard(tool, true)}
</a>
{/each}
</div>
</section>
{/if}
{#if activeUnavail.length > 0}
<section>
<div class="flex items-center gap-2 mb-3">
<span class="size-1.5 rounded-full bg-error shrink-0"></span>
<span class="text-xs uppercase tracking-widest text-error/70"
>Active - unavailable</span
>
<span class="text-xs text-base-content/30"
>{activeUnavail.length}</span
>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
{#each activeUnavail as tool}
<a href="/tools/{tool.name}" class="group">
{@render toolCard(tool, false)}
</a>
{/each}
</div>
</section>
{/if}
{#if active.length + activeMissing.length + activeUnavail.length > 0 && inactive.length + inactiveMissing.length + inactiveUnavail.length > 0}
<div class="divider opacity-20 my-0"></div>
{/if}
{#if inactive.length > 0}
<section>
<div class="flex items-center gap-2 mb-3">
<span class="size-1.5 rounded-full bg-base-content/20 shrink-0"
></span>
<span class="text-xs uppercase tracking-widest text-base-content/30"
>Disabled</span
>
<span class="text-xs text-base-content/20">{inactive.length}</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 opacity-40">
{#each inactive as tool}
<a
href="/tools/{tool.name}"
class="group hover:opacity-100 transition-opacity"
>
{@render toolCard(tool, false)}
</a>
{/each}
</div>
</section>
{/if}
{#if inactiveMissing.length > 0}
<section>
<div class="flex items-center gap-2 mb-3">
<span class="size-1.5 rounded-full bg-base-content/20 shrink-0"
></span>
<span class="text-xs uppercase tracking-widest text-base-content/30"
>Disabled - required config missing</span
>
<span class="text-xs text-base-content/20"
>{inactiveMissing.length}</span
>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 opacity-40">
{#each inactiveMissing as tool}
<a
href="/tools/{tool.name}"
class="group hover:opacity-100 transition-opacity"
>
{@render toolCard(tool, true)}
</a>
{/each}
</div>
</section>
{/if}
{#if inactiveUnavail.length > 0}
<section>
<div class="flex items-center gap-2 mb-3">
<span class="size-1.5 rounded-full bg-base-content/20 shrink-0"
></span>
<span class="text-xs uppercase tracking-widest text-base-content/30"
>Disabled - unavailable</span
>
<span class="text-xs text-base-content/20"
>{inactiveUnavail.length}</span
>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 opacity-40">
{#each inactiveUnavail as tool}
<a
href="/tools/{tool.name}"
class="group hover:opacity-100 transition-opacity"
>
{@render toolCard(tool, false)}
</a>
{/each}
</div>
</section>
{/if}
</div>
{/if}
{/if}