diff --git a/web/src/lib/registry-schema.test.ts b/web/src/lib/registry-schema.test.ts new file mode 100644 index 0000000..88cb3bd --- /dev/null +++ b/web/src/lib/registry-schema.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import { + defaultSchemaParams, + field, + sanitizeSchemaParams, + toolSchema, +} from "./registry-schema"; + +interface FrameParams { + thickness: number; + color: string; + enabled: boolean; + count: string; +} + +const frameSchema = toolSchema( + { + thickness: field.slider({ min: 1, max: 500, default: 5 }), + color: field.color({ default: "#000000" }), + enabled: field.checkbox({ default: true }), + count: field.select({ + default: "two", + options: [ + { value: "one", label: "One" }, + { value: "two", label: "Two" }, + ], + }), + }, + { layout: { group: "frame", cols: 2 }, label: "Frame" }, +); + +describe("registry-schema: дефолты из схемы", () => { + it("собирает дефолты всех полей", () => { + expect(defaultSchemaParams(frameSchema)).toEqual({ + thickness: 5, + color: "#000000", + enabled: true, + count: "two", + }); + }); +}); + +describe("registry-schema: санитайз", () => { + it("пропускает валидные значения", () => { + const out = sanitizeSchemaParams(frameSchema, { + thickness: 40, + color: "#ff0000", + enabled: false, + count: "one", + }); + expect(out).toEqual({ + thickness: 40, + color: "#ff0000", + enabled: false, + count: "one", + }); + }); + + it("заменяет мусор дефолтами", () => { + const out = sanitizeSchemaParams(frameSchema, { + thickness: "abc", + color: "not-a-color", + enabled: "yes", + count: "wat", + extra: 123, + }); + expect(out).toEqual({ + thickness: 5, + color: "#000000", + enabled: true, + count: "two", + }); + }); + + it("clamp-ит числовые поля к min/max", () => { + const out = sanitizeSchemaParams(frameSchema, { + thickness: 9999, + color: "#000000", + enabled: true, + count: "two", + }); + expect(out.thickness).toBe(500); + }); +}); diff --git a/web/src/lib/registry-schema.ts b/web/src/lib/registry-schema.ts new file mode 100644 index 0000000..1e6d7de --- /dev/null +++ b/web/src/lib/registry-schema.ts @@ -0,0 +1,162 @@ +// Новая типизированная схема параметров (для нового UI, старый UI не трогаем). +// +// Параллельный источник правды к старому `ParamDef[]`: старый pipeline +// продолжает работать на `ParamDef[]` как сейчас, а новый строит UI/deфолты/ +// валидацию поверх `ToolSchema

`. +// +// Связка типов на этапе компиляции (защита от рассинхрона): +// - `field.x()` возвращает `Field` — phantom `__fieldT` связывает +// runtime-спецификацию поля с ожидаемым типом значения (`number`|`string`|`boolean`). +// - `toolSchema

(fields)` требует, чтобы ключи `fields` в точности совпадали +// с полями `P`, а `Field` тип-проверялся на соответствие `P[K]`. +// Ошибки компилятора ловят расхождения между интерфейсом Params и схемой. + +export interface NumberSpec { + kind: "number"; + default: number; + min?: number; + max?: number; + step?: number; +} + +export interface SliderSpec { + kind: "slider"; + default: number; + min: number; + max: number; + step?: number; +} + +export interface ColorSpec { + kind: "color"; + default: string; +} + +export interface SelectSpec { + kind: "select"; + default: V; + options: { value: V; label: string }[]; +} + +export interface TextSpec { + kind: "text"; + default: string; + placeholder?: string; +} + +export interface CheckboxSpec { + kind: "checkbox"; + default: boolean; +} + +export type FieldSpec = + NumberSpec | SliderSpec | ColorSpec | SelectSpec | TextSpec | CheckboxSpec; + +/** `Field`: runtime-спека поля + phantom-тип ожидаемого значения (number|string|boolean). */ +export interface Field { + spec: FieldSpec; + /** Phantom, только для типобезопасности — не задаётся и не читается в рантайме. */ + __fieldT?: T; +} + +export interface ToolSchemaMeta { + /** Пер-инструмент раскладка полей (группировка/колонки). */ + layout?: { group?: string; cols?: number }; + /** Короткая подпись инструмента для нового UI (необязательно). */ + label?: string; +} + +export interface ToolSchema

{ + fields: { [K in keyof P]: Field }; + layout?: { group?: string; cols?: number }; + label?: string; +} + +export const field = { + number: (s: Omit): Field => ({ + spec: { kind: "number", ...s }, + }), + slider: (s: Omit): Field => ({ + spec: { kind: "slider", ...s }, + }), + color: (s: Omit): Field => ({ + spec: { kind: "color", ...s }, + }), + select: (s: Omit, "kind">): Field => ({ + spec: { kind: "select", ...s }, + }), + text: (s: Omit): Field => ({ + spec: { kind: "text", ...s }, + }), + checkbox: (s: Omit): Field => ({ + spec: { kind: "checkbox", ...s }, + }), +}; + +/** + * Собирает `ToolSchema

` из объявленных полей. + * Компилятор проверяет: ключи `fields` === полям `P`, типы полей совместимы с `P[K]`. + */ +export function toolSchema

( + fields: { [K in keyof P]: Field }, + meta?: ToolSchemaMeta, +): ToolSchema

{ + return { fields, ...(meta ?? {}) }; +} + +function clamp(value: number, min?: number, max?: number): number { + if (min !== undefined && value < min) return min; + if (max !== undefined && value > max) return max; + return value; +} + +/** Дефолты из схемы — единый источник значений по умолчанию для нового UI. */ +export function defaultSchemaParams

( + schema: ToolSchema

, +): Record { + const out = {} as Record; + for (const key of Object.keys(schema.fields) as (keyof P)[]) { + out[key] = schema.fields[key].spec.default; + } + return out; +} + +/** Валидация/нормализация значений по схеме. Аналог старого `sanitizeParams`. */ +export function sanitizeSchemaParams

( + schema: ToolSchema

, + values: Record, +): Record { + const out = {} as Record; + for (const key of Object.keys(schema.fields) as (keyof P)[]) { + const spec = schema.fields[key].spec; + const raw = values[key as string]; + switch (spec.kind) { + case "number": + case "slider": { + const n = + typeof raw === "number" && Number.isFinite(raw) ? raw : spec.default; + out[key] = clamp(n, spec.min, spec.max); + break; + } + case "select": + out[key] = + typeof raw === "string" && spec.options.some((o) => o.value === raw) + ? raw + : spec.default; + break; + case "checkbox": + out[key] = typeof raw === "boolean" ? raw : spec.default; + break; + case "color": + out[key] = + typeof raw === "string" && /^#[0-9a-f]{6}$/i.test(raw) + ? raw + : spec.default; + break; + case "text": + out[key] = typeof raw === "string" ? raw : spec.default; + break; + } + } + return out; +} diff --git a/web/src/lib/registry.ts b/web/src/lib/registry.ts index 44db494..a799ceb 100644 --- a/web/src/lib/registry.ts +++ b/web/src/lib/registry.ts @@ -1,4 +1,5 @@ import type { CategoryId } from "./categories"; +import type { ToolSchema } from "./registry-schema"; import { ToolError } from "./core/errors"; import { colorMask, @@ -189,34 +190,22 @@ export type OutputFormat = { export type SourceMode = "file" | "none" | "text"; -export type ToolEntry = { +export type ToolEntry

> = { id: string; title: string; description: string; category: CategoryId; sourceMode?: SourceMode; params: ParamDef[]; - run?: ( - img: PixelImage, - params: Record, - ) => Promise | PixelImage; - generate?: ( - params: Record, - ) => Promise | PixelImage; - toText?: ( - img: PixelImage, - params: Record, - ) => Promise | string; - runFromText?: ( - text: string, - params: Record, - ) => Promise | PixelImage; + /** Новая типизированная схема для нового UI. Опциональна — старый UI её не использует. */ + schema?: ToolSchema

; + run?: (img: PixelImage, params: P) => Promise | PixelImage; + generate?: (params: P) => Promise | PixelImage; + toText?: (img: PixelImage, params: P) => Promise | string; + runFromText?: (text: string, params: P) => Promise | PixelImage; /** Текстовый источник + текстовый результат без промежуточного изображения. */ textToText?: (text: string) => Promise | string; - preview?: ( - img: PixelImage, - params: Record, - ) => Promise | PixelImage; + preview?: (img: PixelImage, params: P) => Promise | PixelImage; popularity?: number; icon?: string; resultType?: "image" | "info" | "text";