feat: flatten lib/preview -> lib folder

This commit is contained in:
2026-09-10 20:15:21 +05:00
parent f8a1da6ff7
commit 1e5e096d5d
13 changed files with 10 additions and 14 deletions
+156
View File
@@ -0,0 +1,156 @@
import { ToolError } from "$lib/core/errors";
import type { PixelImage } from "$lib/core/types";
import { sanitizeSchemaParams } from "$lib/registry-schema";
import type { ToolContext, ToolEntry, ToolResult } from "$lib/registry-new";
/** Вход единой точки исполнения. `source`/`text` — по контракту `tool.input`. */
export type ExecuteContext = {
params: Record<string, unknown>;
source?: PixelImage;
text?: string;
};
/**
* Единственная точка исполнения инструмента. Валидирует наличие входа по
* `tool.input`, санитит параметры и запускает `tool.run`. Выбор места
* исполнения (worker/поток) — внутренняя забота executor'а, диспетча методов
* нет: у любого инструмента один `run(ctx)`.
*/
export async function execute(
tool: ToolEntry,
ctx: ExecuteContext,
): Promise<ToolResult> {
const params = sanitizeSchemaParams(tool.schema, ctx.params);
if (tool.input === "image" && !ctx.source) {
throw new ToolError("errors.sourceRequired");
}
if (tool.input === "text" && !ctx.text?.trim()) {
throw new ToolError("errors.textRequired");
}
return route(tool, { params, source: ctx.source, text: ctx.text });
}
/**
* Маршрутизация по способу исполнения. В worker уходит всё, кроме
* domOnly-инструментов: даже текстовые вердикты могут быть тяжёлыми
* (гистограммы, сложный анализ), поэтому ограничивать по типу результата
* неверно. Ограничение одно — доступ к DOM, которого в worker нет.
*/
async function route(
tool: ToolEntry,
ctx: ExecuteContext,
): Promise<ToolResult> {
if (tool.domOnly || typeof Worker === "undefined") {
return await runDirect(tool, ctx);
}
const worker = ensureWorker();
if (worker === null) {
return await runDirect(tool, ctx);
}
try {
return await runInWorker(worker, tool, ctx);
} catch (workerError) {
disableWorker();
void workerError;
return await runDirect(tool, ctx);
}
}
async function runDirect(
tool: ToolEntry,
ctx: ExecuteContext,
): Promise<ToolResult> {
return await tool.run(ctx as ToolContext<Record<string, unknown>>);
}
let worker: Worker | null = null;
let workerTried = false;
let nextRequestId = 1;
const pending = new Map<
number,
{ resolve: (result: ToolResult) => void; reject: (e: Error) => void }
>();
function ensureWorker(): Worker | null {
if (workerTried) return worker;
workerTried = true;
try {
const candidate = new Worker(
new URL("./executor.worker.ts", import.meta.url),
{
type: "module",
},
);
candidate.onmessage = (event: MessageEvent) => {
const payload = event.data as {
id: number;
ok: boolean;
width?: number;
height?: number;
data?: Uint8ClampedArray;
text?: string;
error?: string;
errorKey?: string;
errorVars?: Record<string, string | number>;
};
const entry = pending.get(payload.id);
if (!entry) return;
pending.delete(payload.id);
if (payload.ok && payload.data && payload.width && payload.height) {
entry.resolve({
width: payload.width,
height: payload.height,
data: new Uint8ClampedArray(payload.data),
});
} else if (payload.ok && typeof payload.text === "string") {
entry.resolve(payload.text);
} else if (payload.errorKey) {
entry.reject(new ToolError(payload.errorKey, payload.errorVars));
} else {
entry.reject(new Error(payload.error ?? "errors.workerFailed"));
}
};
candidate.onerror = () => {
worker = null;
for (const entry of pending.values()) {
entry.reject(new Error("errors.workerUnavailable"));
}
pending.clear();
};
worker = candidate;
} catch {
worker = null;
}
return worker;
}
function disableWorker(): void {
if (worker) {
worker.terminate();
}
worker = null;
}
function runInWorker(
workerInstance: Worker,
tool: ToolEntry,
ctx: ExecuteContext,
): Promise<ToolResult> {
return new Promise((resolve, reject) => {
const id = nextRequestId++;
pending.set(id, { resolve, reject });
workerInstance.postMessage({
id,
toolId: tool.id,
params: ctx.params,
source: ctx.source
? {
width: ctx.source.width,
height: ctx.source.height,
data: new Uint8ClampedArray(ctx.source.data),
}
: undefined,
text: ctx.text,
});
});
}
+82
View File
@@ -0,0 +1,82 @@
/// <reference lib="webworker" />
import { ToolError } from "$lib/core/errors";
import type { PixelImage } from "$lib/core/types";
import { getTool } from "$lib/registry-new";
import { sanitizeSchemaParams } from "$lib/registry-schema";
type WorkerRequest = {
id: number;
toolId: string;
params: Record<string, unknown>;
source?: { width: number; height: number; data: Uint8ClampedArray };
text?: string;
};
type WorkerResponse =
| {
id: number;
ok: true;
width?: number;
height?: number;
data?: Uint8ClampedArray;
text?: string;
}
| {
id: number;
ok: false;
error?: string;
errorKey?: string;
errorVars?: Record<string, string | number>;
};
self.onmessage = (event: MessageEvent<WorkerRequest>) => {
void handle(event.data);
};
async function handle(request: WorkerRequest): Promise<void> {
try {
const tool = getTool(request.toolId);
if (!tool?.run) {
throw new Error("errors.noImageRun");
}
const source: PixelImage | undefined = request.source
? {
width: request.source.width,
height: request.source.height,
data: new Uint8ClampedArray(request.source.data),
}
: undefined;
const output = await tool.run({
params: sanitizeSchemaParams(tool.schema, request.params),
source,
text: request.text,
});
if (typeof output === "string") {
(self as unknown as Worker).postMessage({
id: request.id,
ok: true,
text: output,
} satisfies WorkerResponse);
return;
}
(self as unknown as Worker).postMessage(
{
id: request.id,
ok: true,
width: output.width,
height: output.height,
data: output.data,
} satisfies WorkerResponse,
[output.data.buffer],
);
} catch (e) {
const toolError = e instanceof ToolError ? e : undefined;
(self as unknown as Worker).postMessage({
id: request.id,
ok: false,
error: e instanceof Error ? e.message : String(e),
errorKey: toolError?.key,
errorVars: toolError?.vars,
} satisfies WorkerResponse);
}
}
+2
View File
@@ -0,0 +1,2 @@
export { execute } from "./executor";
export type { ExecuteContext } from "./executor";