From 306c26a3602d64a6a24ed938f20c04882f4637c4 Mon Sep 17 00:00:00 2001 From: Ku6epXBOCTuK Date: Mon, 24 Aug 2026 05:52:07 +0500 Subject: [PATCH] feat: add chain tools workflow --- web/src/app.css | 68 +++++++ web/src/lib/components/ToolPage.svelte | 178 ++++++++++++++---- .../components/chain/ChainToolBlock.svelte | 147 +++++++++++++++ .../lib/components/search/ToolSearch.svelte | 19 +- web/src/lib/components/tool/ParamsCard.svelte | 1 - web/src/lib/components/tool/ResultCard.svelte | 26 ++- web/src/routes/+page.svelte | 2 +- 7 files changed, 386 insertions(+), 55 deletions(-) create mode 100644 web/src/lib/components/chain/ChainToolBlock.svelte diff --git a/web/src/app.css b/web/src/app.css index 81d1d90..586e290 100644 --- a/web/src/app.css +++ b/web/src/app.css @@ -154,3 +154,71 @@ input.control[type='number'] { white-space: nowrap; 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); + } +} diff --git a/web/src/lib/components/ToolPage.svelte b/web/src/lib/components/ToolPage.svelte index b102b06..06e163d 100644 --- a/web/src/lib/components/ToolPage.svelte +++ b/web/src/lib/components/ToolPage.svelte @@ -2,7 +2,13 @@ import { imageInfo, type ImageInfo } from '$lib/core/analyze'; import { decodeFile, isSupportedImage, unsupportedImageMessage } from '$lib/core/io'; 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 ParamsCard from './tool/ParamsCard.svelte'; import ResultCard from './tool/ResultCard.svelte'; @@ -22,11 +28,47 @@ let info = $state(null); let errorText = $state(''); let values = $state>({}); + let chain = $state([]); + let chainResults = $state<(PixelImage | null)[]>([]); + let lastRunChainJson = ''; const isInfo = $derived(tool.resultType === 'info'); const isSourceless = $derived(tool.sourceMode === 'none'); const isTextSource = $derived(tool.sourceMode === 'text'); 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(); let hasLastRun = false; @@ -91,6 +133,7 @@ hasLastRun = true; lastRunSource = source; lastRunValuesJson = JSON.stringify(sanitized); + lastRunChainJson = JSON.stringify(chain); try { let next: PixelImage | null = null; let nextText: string | null = null; @@ -116,6 +159,26 @@ result = next; previewResult = nextPreview; 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'; } catch (e) { if (!runner.isCurrent(token)) return; @@ -125,7 +188,15 @@ $effect(() => { 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 (isInfo) return; return runner.schedule(() => void runTool()); @@ -178,7 +249,7 @@ {/if} -
+
{#if !isSourceless}
{#if isTextSource && !source} @@ -206,20 +277,60 @@ {info} {isInfo} params={sanitized} - {textResult} + textResult={textResult} onDownloadError={showError} + onChainToggle={canChainBase ? toggleChain : undefined} + hasChain={chain.length > 0} />
{#if (source || isSourceless) && !isInfo} - +
+ +
{/if} + +
+ {#each chain as step, index (step.id)} + {#if step.toolId === ''} +
+
+

Шаг {index + 1}

+ +
+ applyChainTool(index, id)} /> +
+ {:else if getTool(step.toolId)} + {@const stepTool = getTool(step.toolId)!} + removeChainStep(index)} + onError={showError} + onAddStep={addChainStep} + onRemoveChain={removeChain} + /> + {/if} + {/each} +
diff --git a/web/src/lib/components/chain/ChainToolBlock.svelte b/web/src/lib/components/chain/ChainToolBlock.svelte new file mode 100644 index 0000000..50c8ea9 --- /dev/null +++ b/web/src/lib/components/chain/ChainToolBlock.svelte @@ -0,0 +1,147 @@ + + +
+
+

Шаг {index + 1}: {tool.title}

+ +
+ +
+
+

Вход

+
+ +
+
+
+

Результат

+ {#if busy && !result} +
+ +
+ {:else} +
+ + {#if busy} + Пересчёт… + {/if} +
+
+ + +
+ {/if} +
+
+ + +
+ + diff --git a/web/src/lib/components/search/ToolSearch.svelte b/web/src/lib/components/search/ToolSearch.svelte index 070d5f2..0f316da 100644 --- a/web/src/lib/components/search/ToolSearch.svelte +++ b/web/src/lib/components/search/ToolSearch.svelte @@ -2,11 +2,10 @@ import { isChainable, TOOLS } from '$lib/registry'; interface Props { - size?: 'hero' | 'compact'; onSelect: (toolId: string) => void; } - let { size = 'hero', onSelect }: Props = $props(); + let { onSelect }: Props = $props(); const candidates = TOOLS.filter(isChainable); @@ -51,7 +50,7 @@ } } 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) { @@ -79,7 +78,7 @@ } -