feat: add chain tools workflow

This commit is contained in:
2026-08-24 05:52:07 +05:00
parent c93a8e493e
commit 306c26a360
7 changed files with 386 additions and 55 deletions
+68
View File
@@ -154,3 +154,71 @@ input.control[type='number'] {
white-space: nowrap; white-space: nowrap;
border: 0; border: 0;
} }
.tool-stage {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
padding: var(--space-4);
align-items: start;
width: 100%;
}
.tool-stage.single {
grid-template-columns: 1fr;
}
.tool-stage .cell {
min-width: 0;
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.tool-stage .cell + .cell {
border-left: 1px solid var(--border);
padding-left: var(--space-4);
}
.cell-media {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 14rem;
position: relative;
width: 100%;
}
.chain-stack {
display: flex;
flex-direction: column;
gap: var(--space-3);
margin-top: var(--space-4);
}
.actions-row {
display: flex;
gap: var(--space-1);
align-items: stretch;
width: 100%;
}
.actions-row > button {
flex: 1;
min-width: 0;
}
@media (max-width: 48rem) {
.tool-stage {
grid-template-columns: 1fr;
}
.tool-stage .cell + .cell {
border-left: none;
padding-left: 0;
border-top: 1px solid var(--border);
padding-top: var(--space-4);
}
}
+146 -32
View File
@@ -2,7 +2,13 @@
import { imageInfo, type ImageInfo } from '$lib/core/analyze'; import { imageInfo, type ImageInfo } from '$lib/core/analyze';
import { decodeFile, isSupportedImage, unsupportedImageMessage } from '$lib/core/io'; import { decodeFile, isSupportedImage, unsupportedImageMessage } from '$lib/core/io';
import type { PixelImage } from '$lib/core/types'; import type { PixelImage } from '$lib/core/types';
import { defaultParams, sanitizeParams, type ToolEntry } from '$lib/registry'; import { defaultParams, getTool, outputOf, sanitizeParams, type ToolEntry } from '$lib/registry';
import { newStepId, type PipelineStep } from '$lib/tools/pipeline';
import DownloadButton from './DownloadButton.svelte';
import ParamForm from './ParamForm.svelte';
import Preview from './Preview.svelte';
import ToolSearch from './search/ToolSearch.svelte';
import ChainToolBlock from './chain/ChainToolBlock.svelte';
import { createAutoRunner } from '$lib/tools/auto-run'; import { createAutoRunner } from '$lib/tools/auto-run';
import ParamsCard from './tool/ParamsCard.svelte'; import ParamsCard from './tool/ParamsCard.svelte';
import ResultCard from './tool/ResultCard.svelte'; import ResultCard from './tool/ResultCard.svelte';
@@ -22,11 +28,47 @@
let info = $state<ImageInfo | null>(null); let info = $state<ImageInfo | null>(null);
let errorText = $state(''); let errorText = $state('');
let values = $state<Record<string, any>>({}); let values = $state<Record<string, any>>({});
let chain = $state<PipelineStep[]>([]);
let chainResults = $state<(PixelImage | null)[]>([]);
let lastRunChainJson = '';
const isInfo = $derived(tool.resultType === 'info'); const isInfo = $derived(tool.resultType === 'info');
const isSourceless = $derived(tool.sourceMode === 'none'); const isSourceless = $derived(tool.sourceMode === 'none');
const isTextSource = $derived(tool.sourceMode === 'text'); const isTextSource = $derived(tool.sourceMode === 'text');
const sanitized = $derived(sanitizeParams(tool, values)); const sanitized = $derived(sanitizeParams(tool, values));
const canChainBase = $derived(
(tool.resultType ?? 'image') === 'image' && tool.sourceMode !== 'text'
);
const hasFilledSteps = $derived(chain.some((step) => step.toolId !== ''));
function addChainStep() {
chain.push({ id: newStepId(), toolId: '', values: {} });
}
function removeChainStep(index: number) {
chain.splice(index, 1);
chainResults = [];
}
function removeChain() {
chain.length = 0;
chainResults = [];
}
function toggleChain() {
if (chain.length > 0) {
removeChain();
} else {
addChainStep();
}
}
function applyChainTool(index: number, toolId: string) {
const stepTool = getTool(toolId);
if (!stepTool || !chain[index]) return;
chain[index].toolId = toolId;
chain[index].values = defaultParams(stepTool);
}
const runner = createAutoRunner(); const runner = createAutoRunner();
let hasLastRun = false; let hasLastRun = false;
@@ -91,6 +133,7 @@
hasLastRun = true; hasLastRun = true;
lastRunSource = source; lastRunSource = source;
lastRunValuesJson = JSON.stringify(sanitized); lastRunValuesJson = JSON.stringify(sanitized);
lastRunChainJson = JSON.stringify(chain);
try { try {
let next: PixelImage | null = null; let next: PixelImage | null = null;
let nextText: string | null = null; let nextText: string | null = null;
@@ -116,6 +159,26 @@
result = next; result = next;
previewResult = nextPreview; previewResult = nextPreview;
textResult = nextText; textResult = nextText;
const collected: (PixelImage | null)[] = [];
let current: PixelImage | null = next ?? source;
for (let i = 0; i < chain.length; i++) {
const step = chain[i];
const stepTool = step.toolId === '' ? undefined : getTool(step.toolId);
if (!current || !stepTool?.run) {
collected.push(null);
continue;
}
try {
current = await stepTool.run(current, sanitizeParams(stepTool, step.values));
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
throw new Error(`Шаг ${i + 1} (${stepTool.title}): ${message}`);
}
if (!runner.isCurrent(token)) return;
collected.push(current);
}
chainResults = collected;
status = 'loaded'; status = 'loaded';
} catch (e) { } catch (e) {
if (!runner.isCurrent(token)) return; if (!runner.isCurrent(token)) return;
@@ -125,7 +188,15 @@
$effect(() => { $effect(() => {
const valuesJson = JSON.stringify(sanitized); const valuesJson = JSON.stringify(sanitized);
if (hasLastRun && source === lastRunSource && valuesJson === lastRunValuesJson) return; const chainJson = JSON.stringify(chain);
if (
hasLastRun &&
source === lastRunSource &&
valuesJson === lastRunValuesJson &&
chainJson === lastRunChainJson
) {
return;
}
if (!source && !isSourceless) return; if (!source && !isSourceless) return;
if (isInfo) return; if (isInfo) return;
return runner.schedule(() => void runTool()); return runner.schedule(() => void runTool());
@@ -178,7 +249,7 @@
<div class="error-banner" role="alert">{errorText}</div> <div class="error-banner" role="alert">{errorText}</div>
{/if} {/if}
<div class="stage panel" class:single={isSourceless}> <div class="panel tool-stage" class:single={isSourceless}>
{#if !isSourceless} {#if !isSourceless}
<div class="cell"> <div class="cell">
{#if isTextSource && !source} {#if isTextSource && !source}
@@ -206,20 +277,60 @@
{info} {info}
{isInfo} {isInfo}
params={sanitized} params={sanitized}
{textResult} textResult={textResult}
onDownloadError={showError} onDownloadError={showError}
onChainToggle={canChainBase ? toggleChain : undefined}
hasChain={chain.length > 0}
/> />
</div> </div>
</div> </div>
{#if (source || isSourceless) && !isInfo} {#if (source || isSourceless) && !isInfo}
<ParamsCard <div class="base-params">
params={tool.params} <ParamsCard
bind:values params={tool.params}
{pipetteTargetId} bind:values
onPipetteToggle={handlePipetteToggle} {pipetteTargetId}
/> onPipetteToggle={handlePipetteToggle}
/>
</div>
{/if} {/if}
<div class="chain-stack">
{#each chain as step, index (step.id)}
{#if step.toolId === ''}
<div class="panel empty-slot">
<header>
<h3 class="heading-section">Шаг {index + 1}</h3>
<button
type="button"
class="remove-step"
aria-label="Убрать шаг"
onclick={() => removeChainStep(index)}
>
</button>
</header>
<ToolSearch onSelect={(id) => applyChainTool(index, id)} />
</div>
{:else if getTool(step.toolId)}
{@const stepTool = getTool(step.toolId)!}
<ChainToolBlock
index={index}
tool={stepTool}
bind:values={step.values}
input={index === 0 ? result : (chainResults[index - 1] ?? null)}
result={chainResults[index] ?? null}
busy={status === 'processing'}
isLast={index === chain.length - 1}
onRemove={() => removeChainStep(index)}
onError={showError}
onAddStep={addChainStep}
onRemoveChain={removeChain}
/>
{/if}
{/each}
</div>
</section> </section>
<style> <style>
@@ -236,37 +347,40 @@
margin-bottom: var(--space-3); margin-bottom: var(--space-3);
} }
.stage { .base-params {
display: grid; margin-top: var(--space-4);
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-4);
padding: var(--space-4);
} }
.stage.single { .empty-slot {
grid-template-columns: 1fr; padding: var(--space-3);
} }
.cell { .empty-slot header {
min-width: 0;
display: flex; display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
margin-bottom: var(--space-2);
} }
.cell + .cell { .empty-slot h3 {
border-left: 1px solid var(--border); margin: 0;
padding-left: var(--space-4);
} }
@media (max-width: 48rem) { .remove-step {
.stage { width: 1.6rem;
grid-template-columns: 1fr; height: 1.6rem;
} padding: 0;
border: 1px solid var(--border);
border-radius: var(--radius-s);
background: var(--surface);
color: var(--text-muted);
line-height: 1;
cursor: pointer;
}
.cell + .cell { .remove-step:hover {
border-left: none; border-color: var(--danger);
padding-left: 0; color: var(--danger);
border-top: 1px solid var(--border);
padding-top: var(--space-4);
}
} }
</style> </style>
@@ -0,0 +1,147 @@
<script lang="ts">
import { outputOf, sanitizeParams, type ToolEntry } from '$lib/registry';
import type { PixelImage } from '$lib/core/types';
import DownloadButton from '../DownloadButton.svelte';
import Button from '../ui/Button.svelte';
import EmptyState from '../ui/EmptyState.svelte';
import ParamsCard from '../tool/ParamsCard.svelte';
import Preview from '../Preview.svelte';
interface Props {
index: number;
tool: ToolEntry;
values: Record<string, any>;
input: PixelImage | null;
result: PixelImage | null;
busy: boolean;
isLast: boolean;
onRemove: () => void;
onError: (e: unknown) => void;
onAddStep: () => void;
onRemoveChain: () => void;
}
let {
index,
tool,
values = $bindable(),
input,
result,
busy,
isLast,
onRemove,
onError,
onAddStep,
onRemoveChain
}: Props = $props();
const format = $derived(outputOf(tool));
const safeParams = $derived(sanitizeParams(tool, values));
</script>
<div class="block">
<header>
<h3 class="heading-section">Шаг {index + 1}: {tool.title}</h3>
<button
type="button"
class="remove"
aria-label="Убрать шаг"
title="Убрать шаг"
onclick={onRemove}
>
</button>
</header>
<div class="tool-stage panel">
<div class="cell">
<h4 class="heading-section">Вход</h4>
<div class="cell-media">
<Preview image={input} />
</div>
</div>
<div class="cell">
<h4 class="heading-section">Результат</h4>
{#if busy && !result}
<div class="cell-media">
<EmptyState title="Обработка…" hint="Выполняется шаг цепочки" />
</div>
{:else}
<div class="cell-media">
<Preview image={result} />
{#if busy}
<span class="recalc" aria-live="polite">Пересчёт…</span>
{/if}
</div>
<div class="actions-row">
<DownloadButton
image={result}
format={format}
baseName="{index + 1}-{tool.id}"
params={safeParams}
{onError}
/>
<Button variant="secondary" fullWidth onclick={isLast ? onAddStep : onRemoveChain}>
{isLast ? '⛓ Следующий инструмент' : '✂ Оборвать цепочку'}
</Button>
</div>
{/if}
</div>
</div>
<ParamsCard params={tool.params} bind:values />
</div>
<style>
.block {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
h3 {
margin: 0;
}
.remove {
width: 1.6rem;
height: 1.6rem;
padding: 0;
border: 1px solid var(--border);
border-radius: var(--radius-s);
background: var(--surface);
color: var(--text-muted);
line-height: 1;
cursor: pointer;
transition:
border-color var(--transition-fast),
color var(--transition-fast);
}
.remove:hover {
border-color: var(--danger);
color: var(--danger);
}
h4 {
margin-bottom: var(--space-1);
}
.recalc {
position: absolute;
top: var(--space-2);
right: var(--space-2);
padding: 2px var(--space-2);
border-radius: var(--radius-s);
background: color-mix(in srgb, var(--accent) 12%, var(--surface));
color: var(--accent);
font-size: var(--text-xs);
}
</style>
@@ -2,11 +2,10 @@
import { isChainable, TOOLS } from '$lib/registry'; import { isChainable, TOOLS } from '$lib/registry';
interface Props { interface Props {
size?: 'hero' | 'compact';
onSelect: (toolId: string) => void; onSelect: (toolId: string) => void;
} }
let { size = 'hero', onSelect }: Props = $props(); let { onSelect }: Props = $props();
const candidates = TOOLS.filter(isChainable); const candidates = TOOLS.filter(isChainable);
@@ -51,7 +50,7 @@
} }
} }
found.sort((a, b) => b.score - a.score || a.title.localeCompare(b.title)); found.sort((a, b) => b.score - a.score || a.title.localeCompare(b.title));
return found.slice(0, size === 'hero' ? 12 : 8); return found.slice(0, 12);
}); });
function choose(id: string) { function choose(id: string) {
@@ -79,7 +78,7 @@
} }
</script> </script>
<div class="search {size}"> <div class="search">
<input <input
type="search" type="search"
bind:value={query} bind:value={query}
@@ -120,25 +119,16 @@
.search { .search {
position: relative; position: relative;
width: 100%; width: 100%;
max-width: var(--control-max-width);
}
.hero {
max-width: 36rem; max-width: 36rem;
margin: 0 auto; margin: 0 auto;
} }
input { input {
width: 100%; width: 100%;
padding: var(--space-2) var(--space-3); padding: var(--space-3) var(--space-4);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-m); border-radius: var(--radius-m);
background: var(--surface); background: var(--surface);
font-size: var(--text-l);
}
.hero input {
padding: var(--space-3) var(--space-4);
font-size: var(--text-xl); font-size: var(--text-xl);
text-align: center; text-align: center;
} }
@@ -163,6 +153,7 @@
box-shadow: var(--shadow-card); box-shadow: var(--shadow-card);
max-height: 24rem; max-height: 24rem;
overflow-y: auto; overflow-y: auto;
text-align: left;
} }
li button { li button {
@@ -25,7 +25,6 @@
<style> <style>
.root { .root {
margin-top: var(--space-4);
padding: var(--space-4); padding: var(--space-4);
} }
+19 -7
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import CheckboxField from '../ui/CheckboxField.svelte'; import CheckboxField from '../ui/CheckboxField.svelte';
import Button from '../ui/Button.svelte';
import DownloadButton from '../DownloadButton.svelte'; import DownloadButton from '../DownloadButton.svelte';
import EmptyState from '../ui/EmptyState.svelte'; import EmptyState from '../ui/EmptyState.svelte';
import InfoPanel from '../InfoPanel.svelte'; import InfoPanel from '../InfoPanel.svelte';
@@ -22,6 +23,8 @@
isInfo: boolean; isInfo: boolean;
params: Record<string, unknown>; params: Record<string, unknown>;
textResult: string | null; textResult: string | null;
onChainToggle?: () => void;
hasChain?: boolean;
onDownloadError: (e: unknown) => void; onDownloadError: (e: unknown) => void;
} }
@@ -36,6 +39,8 @@
isInfo, isInfo,
params, params,
textResult, textResult,
onChainToggle,
hasChain = false,
onDownloadError onDownloadError
}: Props = $props(); }: Props = $props();
@@ -81,13 +86,20 @@
<span class="recalc" aria-live="polite">Пересчёт…</span> <span class="recalc" aria-live="polite">Пересчёт…</span>
{/if} {/if}
</div> </div>
<DownloadButton <div class="actions-row">
image={result} <DownloadButton
format={outputOf(tool)} image={result}
baseName={tool.id} format={outputOf(tool)}
{params} baseName={tool.id}
onError={onDownloadError} {params}
/> onError={onDownloadError}
/>
{#if onChainToggle}
<Button variant="secondary" fullWidth onclick={onChainToggle}>
{hasChain ? '✂ Оборвать цепочку' : '⛓ Следующий инструмент'}
</Button>
{/if}
</div>
{/if} {/if}
</div> </div>
+1 -1
View File
@@ -18,7 +18,7 @@
<section class="hero"> <section class="hero">
<h1>Что делаем с изображением?</h1> <h1>Что делаем с изображением?</h1>
<p class="lead text-muted">Найдите инструмент — все операции выполняются локально в браузере.</p> <p class="lead text-muted">Найдите инструмент — все операции выполняются локально в браузере.</p>
<ToolSearch size="hero" onSelect={(id) => (selectedId = id)} /> <ToolSearch onSelect={(id) => (selectedId = id)} />
</section> </section>
{:else} {:else}
<section class="workbench"> <section class="workbench">