feat: add text core tools and views

This commit is contained in:
2026-09-07 10:25:17 +05:00
parent c0cebdc81f
commit a49785764b
7 changed files with 432 additions and 14 deletions
@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
import MetaList from "$lib/components/kit/MetaList.svelte"; import MetaList from "$lib/components/kit/MetaList.svelte";
import DownloadButton from "$lib/components/kit/ui/DownloadButton.svelte"; import DownloadButton from "$lib/components/kit/ui/DownloadButton.svelte";
import SchemaTextResult from "$lib/components/kit/SchemaTextResult.svelte";
import SchemaTextSource from "$lib/components/kit/SchemaTextSource.svelte";
import { toDataUrl } from "$lib/core/io"; import { toDataUrl } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types"; import type { PixelImage } from "$lib/core/types";
import { Check, Sparkles, Upload } from "@lucide/svelte"; import { Check, Sparkles, Upload } from "@lucide/svelte";
@@ -8,24 +10,41 @@
interface Props { interface Props {
source: PixelImage | null; source: PixelImage | null;
result: PixelImage | null; result: PixelImage | null;
textSource?: string;
textResult?: string | null;
resultKind?: "image" | "text" | "verdict";
running: boolean; running: boolean;
error: string; error: string;
isGenerator?: boolean; isGenerator?: boolean;
onupload: (file: File) => void; onupload: (file: File) => void;
ongenerate?: () => void; ongenerate?: () => void;
ontextsource?: (text: string) => void;
onrendertext?: () => void;
oncopytext?: () => void;
ondownloadtxt?: () => void;
ondownload: () => void; ondownload: () => void;
} }
let { let {
source, source,
result, result,
textSource = "",
textResult = null,
resultKind = "image",
running, running,
error, error,
isGenerator = false, isGenerator = false,
onupload, onupload,
ongenerate, ongenerate,
ontextsource,
onrendertext,
oncopytext,
ondownloadtxt,
ondownload, ondownload,
}: Props = $props(); }: Props = $props();
const isTextInput = $derived(ontextsource !== undefined && !isGenerator);
const isImageDownload = $derived(resultKind === "image" && Boolean(result));
let sourceUrl = $derived(source ? toDataUrl(source) : null); let sourceUrl = $derived(source ? toDataUrl(source) : null);
let resultUrl = $derived(result ? toDataUrl(result) : null); let resultUrl = $derived(result ? toDataUrl(result) : null);
</script> </script>
@@ -44,7 +63,7 @@
<Sparkles size={14} /> <Sparkles size={14} />
{running ? "Generating…" : "Generate"} {running ? "Generating…" : "Generate"}
</button> </button>
{:else} {:else if !isTextInput}
<label class="upload"> <label class="upload">
<Upload size={14} /> Open image <Upload size={14} /> Open image
<input <input
@@ -57,7 +76,9 @@
/> />
</label> </label>
{/if} {/if}
<DownloadButton label="Download result" onclick={ondownload} /> {#if isImageDownload}
<DownloadButton label="Download result" onclick={ondownload} />
{/if}
</div> </div>
</div> </div>
@@ -70,7 +91,16 @@
<figure class="tile"> <figure class="tile">
<figcaption><span>SOURCE</span></figcaption> <figcaption><span>SOURCE</span></figcaption>
<div class="canvas"> <div class="canvas">
{#if sourceUrl} {#if isTextInput}
<div class="text-source-wrap">
<SchemaTextSource
value={textSource}
disabled={running}
oninput={ontextsource ?? (() => {})}
onrender={onrendertext ?? (() => {})}
/>
</div>
{:else if sourceUrl}
<img src={sourceUrl} alt="source" /> <img src={sourceUrl} alt="source" />
{:else} {:else}
<span class="empty">choose an image</span> <span class="empty">choose an image</span>
@@ -78,10 +108,28 @@
</div> </div>
</figure> </figure>
{/if} {/if}
<figure class="tile" class:full={isGenerator}> <figure class="tile" class:full={isGenerator || resultKind !== "image"}>
<figcaption><span>RESULT {running ? "…" : ""}</span></figcaption> <figcaption><span>RESULT {running ? "…" : ""}</span></figcaption>
<div class="canvas checker"> <div class="canvas" class:checker={resultKind === "image"}>
{#if resultUrl} {#if resultKind === "text" && textResult}
<div class="text-result-wrap">
<SchemaTextResult
value={textResult}
kind="text"
oncopy={oncopytext ?? (() => {})}
ondownload={ondownloadtxt ?? (() => {})}
/>
</div>
{:else if resultKind === "verdict" && textResult}
<div class="text-result-wrap">
<SchemaTextResult
value={textResult}
kind="verdict"
oncopy={oncopytext ?? (() => {})}
ondownload={ondownloadtxt ?? (() => {})}
/>
</div>
{:else if resultKind === "image" && resultUrl}
<img src={resultUrl} alt="result" /> <img src={resultUrl} alt="result" />
{:else if running} {:else if running}
<Check size={22} /> <Check size={22} />
@@ -99,11 +147,25 @@
items={[ items={[
{ {
caption: "SOURCE", caption: "SOURCE",
value: source ? `${source.width} × ${source.height} px` : "—", value:
!isGenerator && !isTextInput
? source
? `${source.width} × ${source.height} px`
: "—"
: isTextInput
? "text"
: "—",
}, },
{ {
caption: "RESULT", caption: "RESULT",
value: result ? `${result.width} × ${result.height} px` : "—", value:
resultKind === "image"
? result
? `${result.width} × ${result.height} px`
: "—"
: textResult
? "text"
: "—",
}, },
{ caption: "FORMAT", value: "PNG" }, { caption: "FORMAT", value: "PNG" },
]} ]}
@@ -202,6 +264,12 @@
.empty { .empty {
font: var(--font-size-s) var(--font-mono); font: var(--font-size-s) var(--font-mono);
} }
.text-source-wrap,
.text-result-wrap {
width: 100%;
padding: var(--space-l);
box-sizing: border-box;
}
.meta { .meta {
margin-top: var(--space-l); margin-top: var(--space-l);
} }
@@ -0,0 +1,129 @@
<script lang="ts">
import { Copy, Download, FileText } from "@lucide/svelte";
import IconButton from "./ui/IconButton.svelte";
interface Props {
value: string;
kind: "text" | "verdict";
fileName?: string;
oncopy: () => void;
ondownload: () => void;
}
let {
value,
kind,
fileName = "result.txt",
oncopy,
ondownload,
}: Props = $props();
const tone = $derived(
/^(yes|true)/i.test(value.trim())
? "success"
: /^(no|false)/i.test(value.trim())
? "danger"
: "info",
);
</script>
{#if kind === "verdict"}
<div class="verdict">
<span class="verdict-label">RESULT</span>
<div class="badge badge-tone--{tone}" role="status">
<span class="verdict-text">{value}</span>
<IconButton icon={Copy} label="Copy" onclick={oncopy} />
</div>
</div>
{:else}
<div class="text-result">
<div class="result-head">
<span class="result-label"><FileText size={14} /> {fileName}</span>
<div class="result-actions">
<IconButton icon={Copy} label="Copy" onclick={oncopy} />
<IconButton
icon={Download}
label="Download .txt"
onclick={ondownload}
/>
</div>
</div>
<pre class="result-pre"><code>{value}</code></pre>
</div>
{/if}
<style>
.text-result {
display: grid;
gap: var(--space-m);
min-width: 0;
}
.result-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-m);
}
.result-label {
display: inline-flex;
align-items: center;
gap: var(--space-m);
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-l);
}
.result-actions {
display: flex;
gap: var(--space-s);
}
.result-pre {
margin: 0;
box-sizing: border-box;
max-height: var(--space-xxxl);
overflow: auto;
padding: var(--space-l) var(--space-m);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-m);
background: var(--color-background);
color: var(--color-text);
font: var(--font-size-s) var(--font-mono);
line-height: 1.5;
white-space: pre;
}
.verdict {
display: grid;
gap: var(--space-m);
justify-items: start;
}
.verdict-label {
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
letter-spacing: var(--space-text-l);
}
.badge {
display: inline-flex;
align-items: center;
gap: var(--space-l);
padding: var(--space-m) var(--space-xl);
border: var(--size-border-thick) solid transparent;
border-radius: var(--radius-m);
font: var(--font-size-m) var(--font-mono);
}
.badge-tone--success {
background: var(--color-success-tint);
border-color: var(--color-success);
color: var(--color-success);
}
.badge-tone--danger {
background: var(--color-danger-tint);
border-color: var(--color-danger);
color: var(--color-danger);
}
.badge-tone--info {
background: var(--color-info-tint);
border-color: var(--color-info);
color: var(--color-info);
}
.verdict-text {
line-height: normal;
}
</style>
@@ -0,0 +1,107 @@
<script lang="ts">
import { ArrowUp, Upload } from "@lucide/svelte";
interface Props {
value: string;
placeholder?: string;
disabled?: boolean;
oninput: (text: string) => void;
onrender: () => void;
onsample?: () => void;
}
let {
value,
placeholder = "Paste base64 / hex / bytes…",
disabled = false,
oninput,
onrender,
onsample,
}: Props = $props();
</script>
<div class="text-source">
<textarea
spellcheck="false"
{placeholder}
rows="6"
{value}
oninput={(e) => oninput((e.target as HTMLTextAreaElement).value)}
onkeydown={(e) => {
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
onrender();
}
}}></textarea>
<div class="actions">
{#if onsample}
<button type="button" class="secondary" onclick={onsample}>
<Upload size={14} /> Try sample
</button>
{/if}
<button type="button" class="render" onclick={onrender} {disabled}>
<ArrowUp size={14} /> Render text
</button>
</div>
</div>
<style>
.text-source {
display: grid;
gap: var(--space-m);
}
textarea {
width: 100%;
min-height: var(--space-brand);
max-height: var(--space-xxxl);
resize: vertical;
box-sizing: border-box;
padding: var(--space-m);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-m);
background: var(--color-background);
color: var(--color-text);
font: var(--font-size-s) var(--font-mono);
line-height: 1.5;
}
textarea:focus {
outline: none;
border-color: var(--color-main);
}
.actions {
display: flex;
gap: var(--space-m);
justify-content: flex-end;
}
.render {
display: inline-flex;
align-items: 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: var(--font-size-s) var(--font-mono);
cursor: pointer;
}
.render:disabled {
opacity: 0.6;
cursor: default;
}
.secondary {
display: inline-flex;
align-items: center;
gap: var(--space-m);
padding: var(--space-m) var(--space-l);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-m);
background: none;
color: var(--color-text-muted);
font: var(--font-size-s) var(--font-mono);
cursor: pointer;
}
.secondary:hover {
color: var(--color-text);
border-color: var(--color-text-muted);
}
</style>
@@ -4,7 +4,13 @@
import { debounce } from "$lib/core/debounce"; import { debounce } from "$lib/core/debounce";
import { decodeFile, encode } from "$lib/core/io"; import { decodeFile, encode } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types"; import type { PixelImage } from "$lib/core/types";
import { executeGenerate, executeStep } from "$lib/preview/executor"; import {
executeFromText,
executeGenerate,
executeStep,
executeTextToText,
executeToText,
} from "$lib/preview/executor";
import type { ToolEntry } from "$lib/registry-new"; import type { ToolEntry } from "$lib/registry-new";
import { import {
defaultSchemaParams, defaultSchemaParams,
@@ -22,10 +28,14 @@
); );
const isGenerator = $derived(Boolean(tool.generate && !tool.run)); const isGenerator = $derived(Boolean(tool.generate && !tool.run));
const inputMode = $derived(tool.input ?? "file");
const resultKind = $derived(tool.result ?? "image");
let values = $state<Record<string, unknown>>({}); let values = $state<Record<string, unknown>>({});
let source = $state<PixelImage | null>(null); let source = $state<PixelImage | null>(null);
let result = $state<PixelImage | null>(null); let result = $state<PixelImage | null>(null);
let textSource = $state("");
let textResult = $state<string | null>(null);
let running = $state(false); let running = $state(false);
let error = $state(""); let error = $state("");
let started = $state(false); let started = $state(false);
@@ -45,6 +55,7 @@
function reset() { function reset() {
values = schema ? defaultSchemaParams(schema) : {}; values = schema ? defaultSchemaParams(schema) : {};
result = null; result = null;
textResult = null;
error = ""; error = "";
} }
@@ -64,12 +75,29 @@
await runGenerate(); await runGenerate();
return; return;
} }
if (!source) return;
error = ""; error = "";
running = true;
const params = sanitizeSchemaParams(schema, values); const params = sanitizeSchemaParams(schema, values);
running = true;
try { try {
result = await executeStep(tool, source, params); if (inputMode === "text") {
if (!textSource.trim()) return;
if (tool.textToText) {
textResult = await executeTextToText(tool, textSource);
result = null;
} else {
result = await executeFromText(tool, textSource, params);
textResult = null;
}
} else {
if (!source) return;
if (resultKind !== "image" && tool.toText) {
textResult = await executeToText(tool, source, params);
result = null;
} else {
result = await executeStep(tool, source, params);
textResult = null;
}
}
} catch (e) { } catch (e) {
error = errorText(e); error = errorText(e);
} finally { } finally {
@@ -106,12 +134,32 @@
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
async function copyText() {
if (!textResult) return;
try {
await navigator.clipboard.writeText(textResult);
} catch (e) {
error = errorText(e);
}
}
async function downloadText() {
if (!textResult) return;
const blob = new Blob([textResult], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${tool.id}.txt`;
a.click();
URL.revokeObjectURL(url);
}
$effect(() => { $effect(() => {
init(); init();
}); });
$effect(() => { $effect(() => {
if (isGenerator || !source || !started) return; if (inputMode === "text" || isGenerator || !source || !started) return;
void values; void values;
debouncedRun(); debouncedRun();
return () => debouncedRun.cancel(); return () => debouncedRun.cancel();
@@ -146,11 +194,20 @@
<SchemaPreview <SchemaPreview
{source} {source}
{result} {result}
{textSource}
{textResult}
{resultKind}
{running} {running}
{error} {error}
{isGenerator} {isGenerator}
onupload={handleFile} onupload={handleFile}
ongenerate={runGenerate} ongenerate={runGenerate}
ontextsource={(t) => {
textSource = t;
}}
onrendertext={run}
oncopytext={copyText}
ondownloadtxt={downloadText}
ondownload={download} ondownload={download}
/> />
</section> </section>
+47
View File
@@ -55,6 +55,53 @@ export async function executeGenerate(
return await tool.generate(params); return await tool.generate(params);
} }
type MaybeTextRunnable = MaybeRunnable & {
runFromText?: (
text: string,
params: Record<string, unknown>,
) => Promise<PixelImage> | PixelImage;
toText?: (
img: PixelImage,
params: Record<string, unknown>,
) => Promise<string> | string;
textToText?: (text: string) => Promise<string> | string;
};
/** Текст → изображение (text-source конвертеры). Выполняется напрямую. */
export async function executeFromText(
tool: MaybeTextRunnable,
text: string,
params: Record<string, unknown>,
): Promise<PixelImage> {
if (!tool.runFromText) {
throw new Error("errors.noTextInput");
}
return await tool.runFromText(text, params);
}
/** Изображение → текст (текстовые конвертеры и вердикты). */
export async function executeToText(
tool: MaybeTextRunnable,
img: PixelImage,
params: Record<string, unknown>,
): Promise<string> {
if (!tool.toText) {
throw new Error("errors.noTextResult");
}
return await tool.toText(img, params);
}
/** Текст → текст (напр. verify-is-png). */
export async function executeTextToText(
tool: MaybeTextRunnable,
text: string,
): Promise<string> {
if (!tool.textToText) {
throw new Error("errors.noTextResult");
}
return await tool.textToText(text);
}
async function runDirect( async function runDirect(
tool: MaybeRunnable, tool: MaybeRunnable,
img: PixelImage, img: PixelImage,
+7 -1
View File
@@ -1 +1,7 @@
export { executeGenerate, executeStep } from "./executor"; export {
executeFromText,
executeGenerate,
executeStep,
executeTextToText,
executeToText,
} from "./executor";
+4
View File
@@ -23,6 +23,10 @@ export type ToolEntry<P = Record<string, unknown>> = {
schema: ToolSchema<P>; schema: ToolSchema<P>;
/** Требует DOM (canvas/document); превью-executor запускает напрямую, не в worker. */ /** Требует DOM (canvas/document); превью-executor запускает напрямую, не в worker. */
domOnly?: boolean; domOnly?: boolean;
/** Чем управляется инструмент: файлом (по умолчанию), текстом или ничем (генератор). */
input?: "file" | "text" | "none";
/** Тип результата: картинка (по умолчанию), большой текст или короткий вердикт. */
result?: "image" | "text" | "verdict";
/** Применение к входному изображению. Для чистых генераторов отсутствует. */ /** Применение к входному изображению. Для чистых генераторов отсутствует. */
run?(img: PixelImage, params: P): Promise<PixelImage> | PixelImage; run?(img: PixelImage, params: P): Promise<PixelImage> | PixelImage;
generate?(params: P): Promise<PixelImage> | PixelImage; generate?(params: P): Promise<PixelImage> | PixelImage;