feat: change tool inplace

This commit is contained in:
2026-10-06 19:34:11 +05:00
parent 5c58451f2f
commit 1ba03b731d
5 changed files with 168 additions and 105 deletions
@@ -1,123 +1,21 @@
<script lang="ts">
import Icon from "$lib/components/ui/Icon.svelte";
import { pageTitle } from "$lib/i18n/schema-tool-strings";
import { t } from "$lib/i18n/t";
import { chainablePages } from "$lib/pipeline.svelte";
import type { Page } from "$lib/registry";
import { Plus } from "@lucide/svelte";
import ToolPickerButton from "./ToolPickerButton.svelte";
interface Props {
onadd: (toolId: string) => void;
}
let { onadd }: Props = $props();
const pages = chainablePages();
let open = $state(false);
let root = $state<HTMLDivElement | null>(null);
function choose(page: Page) {
onadd(page.steps[0].id);
open = false;
}
$effect(() => {
if (!open) return;
function onPointerDown(e: PointerEvent) {
if (!root?.contains(e.target as Node)) open = false;
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") open = false;
}
window.addEventListener("pointerdown", onPointerDown);
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("keydown", onKey);
};
});
</script>
<div class="add-step" bind:this={root}>
<button
type="button"
class="add-btn"
aria-haspopup="menu"
aria-expanded={open}
onclick={() => (open = !open)}
>
<Icon icon={Plus} size={14} />
<span>{t("chain.addStep")}</span>
</button>
{#if open}
<div class="menu" role="menu">
{#each pages as page (page.slug)}
<button
type="button"
class="item"
role="menuitem"
onclick={() => choose(page)}
>
{pageTitle(page)}
</button>
{/each}
</div>
{/if}
<div class="add-step">
<ToolPickerButton label={t("chain.addStep")} {onadd} />
</div>
<style>
.add-step {
position: relative;
display: flex;
justify-content: flex-end;
margin: var(--space-s) 0;
}
.add-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-m);
padding: var(--space-m) var(--space-l);
border: var(--size-border) solid var(--color-main);
border-radius: var(--radius-m);
background: var(--color-main);
color: var(--color-background);
font: 600 var(--font-size-s) var(--font-mono);
cursor: pointer;
}
.add-btn:hover {
filter: brightness(1.1);
}
.menu {
position: absolute;
top: calc(100% + var(--space-s));
right: 0;
z-index: var(--z-dropdown);
min-width: calc(var(--space-xxxl) * 8);
max-height: calc(var(--space-xxxl) * 8);
overflow-y: auto;
display: grid;
gap: var(--space-s);
padding: var(--space-m);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-m);
background: var(--color-panel);
}
.item {
padding: var(--space-m) var(--space-l);
border: var(--size-border) solid transparent;
border-radius: var(--radius-s);
background: transparent;
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
text-align: left;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item:hover {
color: var(--color-text);
border-color: var(--color-border);
}
</style>
@@ -2,6 +2,7 @@
import AddStepButton from "./AddStepButton.svelte";
import SchemaFields from "./SchemaFields.svelte";
import SchemaPreview from "./SchemaPreview.svelte";
import ToolPickerButton from "./ToolPickerButton.svelte";
import StepCard from "$lib/components/display/StepCard.svelte";
import { hasTransparency } from "$lib/core/analyze";
import { debounce } from "$lib/core/debounce";
@@ -23,6 +24,7 @@
import { t } from "$lib/i18n/t";
import {
createChain,
createStep,
insertStep,
loadChain,
moveStep,
@@ -246,6 +248,20 @@
steps = steps.with(index, { ...step, collapsed: !step.collapsed });
}
// Замена инструмента на месте: ключ и свёрнутость шага сохраняются,
// параметры — дефолты нового инструмента, вход пересчитывается при прогоне.
function replaceStepTool(index: number, toolId: string) {
const step = steps[index];
const next = createStep(toolId);
if (!step || !next) return;
touchedFor(step.key).clear();
steps = steps.with(index, {
...next,
key: step.key,
collapsed: step.collapsed,
});
}
function onStepDrop(e: DragEvent, to: number) {
e.preventDefault();
if (dragFrom !== null) steps = moveStep(steps, dragFrom, to);
@@ -483,6 +499,13 @@
ondrop={(e) => onStepDrop(e, i)}
ondragend={() => (dragFrom = null)}
>
{#snippet tools()}
<ToolPickerButton
label={t("chain.changeTool")}
iconOnly
onadd={(id) => replaceStepTool(i, id)}
/>
{/snippet}
<SchemaFields
schema={toolSchemaOf(step)}
values={step.params}
@@ -0,0 +1,140 @@
<script lang="ts">
import Icon from "$lib/components/ui/Icon.svelte";
import { pageTitle } from "$lib/i18n/schema-tool-strings";
import { chainablePages } from "$lib/pipeline.svelte";
import type { Page } from "$lib/registry";
import { Plus, Repeat } from "@lucide/svelte";
interface Props {
label: string;
iconOnly?: boolean;
onadd: (toolId: string) => void;
}
let { label, iconOnly = false, onadd }: Props = $props();
const pages = chainablePages();
let open = $state(false);
let root = $state<HTMLDivElement | null>(null);
function choose(page: Page) {
onadd(page.steps[0].id);
open = false;
}
$effect(() => {
if (!open) return;
function onPointerDown(e: PointerEvent) {
if (!root?.contains(e.target as Node)) open = false;
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") open = false;
}
window.addEventListener("pointerdown", onPointerDown);
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("pointerdown", onPointerDown);
window.removeEventListener("keydown", onKey);
};
});
</script>
<div class="tool-picker" bind:this={root}>
<button
type="button"
class={iconOnly ? "icon-btn" : "add-btn"}
aria-haspopup="menu"
aria-expanded={open}
aria-label={iconOnly ? label : undefined}
onclick={() => (open = !open)}
>
{#if iconOnly}
<Icon icon={Repeat} size={14} />
{:else}
<Icon icon={Plus} size={14} />
<span>{label}</span>
{/if}
</button>
{#if open}
<div class="menu" role="menu">
{#each pages as page (page.slug)}
<button
type="button"
class="item"
role="menuitem"
onclick={() => choose(page)}
>
{pageTitle(page)}
</button>
{/each}
</div>
{/if}
</div>
<style>
.tool-picker {
position: relative;
display: inline-flex;
}
.add-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-m);
padding: var(--space-m) var(--space-l);
border: var(--size-border) solid var(--color-main);
border-radius: var(--radius-m);
background: var(--color-main);
color: var(--color-background);
font: 600 var(--font-size-s) var(--font-mono);
cursor: pointer;
}
.add-btn:hover {
filter: brightness(1.1);
}
.icon-btn {
display: inline-flex;
align-items: center;
padding: var(--space-s);
border: var(--size-border) solid transparent;
border-radius: var(--radius-m);
background: none;
color: var(--color-text-muted);
cursor: pointer;
}
.icon-btn:hover {
color: var(--color-text);
}
.menu {
position: absolute;
top: calc(100% + var(--space-s));
right: 0;
z-index: var(--z-dropdown);
min-width: calc(var(--space-xxxl) * 8);
max-height: calc(var(--space-xxxl) * 8);
overflow-y: auto;
display: grid;
gap: var(--space-s);
padding: var(--space-m);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-m);
background: var(--color-panel);
}
.item {
padding: var(--space-m) var(--space-l);
border: var(--size-border) solid transparent;
border-radius: var(--radius-s);
background: transparent;
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
text-align: left;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item:hover {
color: var(--color-text);
border-color: var(--color-border);
}
</style>