短いpromptからdashboardのHTMLを出すところまでは、もう難しくありません。困るのはそのあとです。
許可したtagはどれか。勝手なURLやevent handlerが混ざっていないか。layoutが崩れたとき、promptとCSSと生成結果のどこを直すのか。画面が一度出たことより、毎回reviewできることのほうが重要になります。
自分なら、modelにはHTMLもJSXも返させません。返させるのは、appが用意したblockを選ぶ小さなJSON documentだけです。
model output (unknown)
-> BlockDocumentSchema.safeParse()
-> Block[]
-> renderer registry
-> React elements
modelの仕事は、appが許可したUI vocabularyを選ぶところまでです。その先の境界をReact、TypeScript、Zodで作ります。
まずmodelを外す
生成UIの実装で先にmodel SDKへつなぐと、schemaの問題とmodel出力の揺れを同時にdebugすることになります。最初は固定fixtureで十分です。
React + TypeScriptのprojectへZodを追加します。test例ではVitestを使います。
pnpm add zod
pnpm add -D vitest
最初に許すblockは3種類だけにします。
-
heading: セクション見出し -
paragraph: 本文 -
stat: ラベル付きの数値や短い値
link、image、button、入れ子layoutはまだ入れません。便利なblockほど、URL、権限、深さといった別のpolicyが必要になるからです。
const fixture: unknown = {
schemaVersion: 1,
blocks: [
{
id: "heading-1",
type: "heading",
level: 2,
text: "今週の利用状況",
},
{
id: "paragraph-1",
type: "paragraph",
text: "集計済みのイベントだけを表示しています。",
},
{
id: "stat-1",
type: "stat",
label: "完了したtask",
value: "18",
tone: "positive",
},
],
};
ここでは型を付けず、意図的にunknownで受けます。外部modelの返り値は、TypeScriptの型注釈を書いても実行時には信用できません。
Zodを型の正本にする
schemaは「このappが描画してよいか」を判定します。JSONとして読めるだけでは通しません。
import { z } from "zod";
const BlockIdSchema = z.string().trim().min(1).max(64);
const HeadingBlockSchema = z
.object({
id: BlockIdSchema,
type: z.literal("heading"),
level: z.union([z.literal(2), z.literal(3)]),
text: z.string().trim().min(1).max(80),
})
.strict();
const ParagraphBlockSchema = z
.object({
id: BlockIdSchema,
type: z.literal("paragraph"),
text: z.string().trim().min(1).max(600),
})
.strict();
const StatBlockSchema = z
.object({
id: BlockIdSchema,
type: z.literal("stat"),
label: z.string().trim().min(1).max(40),
value: z.string().trim().min(1).max(80),
tone: z.enum(["neutral", "positive", "warning"]),
})
.strict();
export const BlockSchema = z.discriminatedUnion("type", [
HeadingBlockSchema,
ParagraphBlockSchema,
StatBlockSchema,
]);
export const BlockDocumentSchema = z
.object({
schemaVersion: z.literal(1),
blocks: z.array(BlockSchema).min(1).max(12),
})
.strict();
export type Block = z.infer<typeof BlockSchema>;
export type BlockDocument = z.infer<typeof BlockDocumentSchema>;
.strict()を付けたのは、modelが足した未知fieldを黙って捨てないためです。たとえばparagraphへhtmlが混ざったらparseを失敗させます。受け入れたつもりのないfieldを裏側に残すより、schemaの変更としてreviewしたほうが追えます。
文字数やblock数の上限は記事用の例です。実際の値は画面とproduct要件に合わせます。ただし、上限そのものは早い段階で入れます。構造が正しくても、数千blockを一度に描画してよいとは限りません。
z.inferからBlockを作るので、同じunionをTypeScript側へ手書きしません。schemaと型が別々に育つ状態を避けられます。
renderer registryで描画先を固定する
検証後のblockは、登録済みのReact componentだけへ渡します。文字列からcomponent名を組み立てたり、動的importしたりはしません。
import { Fragment, type ReactNode } from "react";
import type { Block } from "./block-schema";
type BlockType = Block["type"];
type BlockOf<K extends BlockType> = Extract<Block, { type: K }>;
type RendererMap = {
[K in BlockType]: (block: BlockOf<K>) => ReactNode;
};
const renderers = {
heading: (block) =>
block.level === 2 ? (
<h2>{block.text}</h2>
) : (
<h3>{block.text}</h3>
),
paragraph: (block) => <p>{block.text}</p>,
stat: (block) => (
<dl data-tone={block.tone}>
<dt>{block.label}</dt>
<dd>{block.value}</dd>
</dl>
),
} satisfies RendererMap;
function assertNever(value: never): never {
throw new Error(`Unhandled block: ${JSON.stringify(value)}`);
}
function renderBlock(block: Block): ReactNode {
switch (block.type) {
case "heading":
return renderers.heading(block);
case "paragraph":
return renderers.paragraph(block);
case "stat":
return renderers.stat(block);
default:
return assertNever(block);
}
}
export function BlockList({ blocks }: { blocks: Block[] }) {
return (
<section>
{blocks.map((block) => (
<Fragment key={block.id}>{renderBlock(block)}</Fragment>
))}
</section>
);
}
RendererMapは、各typeに対応するrendererを要求します。schemaへimageを追加してregistryを更新し忘れると、satisfies RendererMapでcompile errorになります。switch側もassertNeverで追加漏れを検出できます。
少し重複して見えますが、ここは自動的に丸めないほうがよい場所です。block追加のdiffに「入力schema」と「描画component」が並ぶので、reviewで変更範囲を確認できます。
Reactはblock.textをtext nodeとして扱います。raw HTMLを通す経路がないため、dangerouslySetInnerHTMLも不要です。未登録のtypeを汎用HTML rendererへ逃がすと、この境界が消えます。
parse失敗をUIの状態として返す
safeParse()が失敗したときに空配列を返すと、ユーザーからは「生成結果が空だった」のか「検証に失敗した」のか分かりません。不正なdocumentはfallbackへ分けます。
import {
BlockDocumentSchema,
type BlockDocument,
} from "./block-schema";
import { BlockList } from "./block-list";
type ParseIssue = {
path: string;
code: string;
};
type ParsedDocument =
| {
ok: true;
value: BlockDocument;
}
| {
ok: false;
issues: ParseIssue[];
};
export function parseBlockDocument(raw: unknown): ParsedDocument {
const result = BlockDocumentSchema.safeParse(raw);
if (result.success) {
return { ok: true, value: result.data };
}
return {
ok: false,
issues: result.error.issues.map((issue) => ({
path: issue.path.join("."),
code: issue.code,
})),
};
}
function InvalidGeneratedView({ onRetry }: { onRetry: () => void }) {
return (
<section role="alert">
<p>表示を組み立てられませんでした。</p>
<button type="button" onClick={onRetry}>
もう一度生成する
</button>
</section>
);
}
export function GeneratedView({
raw,
onRetry,
}: {
raw: unknown;
onRetry: () => void;
}) {
const parsed = parseBlockDocument(raw);
if (!parsed.ok) {
return <InvalidGeneratedView onRetry={onRetry} />;
}
return <BlockList blocks={parsed.value.blocks} />;
}
上の例ではparseBlockDocument()をcomponentから呼んでいます。productionでは、model responseを受け取る境界で一度だけparseし、成功値か失敗値をstateへ保存するほうが扱いやすいです。renderのたびに同じerror logを送る事故も避けられます。
開発用のlogに残すなら、Zodのissueからpath、code、schema version、request IDあたりに絞ります。prompt全文や秘密値をそのまま保存する必要はありません。同じ不正出力に対する自動retryにも上限を持たせます。
なお、Zodが確認するのは構造です。認可、prompt injection対策、URLの安全性、外部resourceのsandboxまで面倒を見てくれるわけではありません。
modelを呼ばずに壊れ方をtestする
生成UIはhappy pathのscreenshotより、拒否したい入力をfixtureにしたほうが境界を確認しやすいです。
import { describe, expect, it } from "vitest";
import { parseBlockDocument } from "./generated-view";
const validDocument: unknown = {
schemaVersion: 1,
blocks: [
{ id: "h-1", type: "heading", level: 2, text: "利用状況" },
{ id: "p-1", type: "paragraph", text: "直近7日間の集計です。" },
{
id: "s-1",
type: "stat",
label: "完了したtask",
value: "18",
tone: "positive",
},
],
};
describe("parseBlockDocument", () => {
it("正常なblockの順序を保つ", () => {
const result = parseBlockDocument(validDocument);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.value.blocks.map((block) => block.id)).toEqual([
"h-1",
"p-1",
"s-1",
]);
}
});
it("未知のtypeを拒否する", () => {
const result = parseBlockDocument({
schemaVersion: 1,
blocks: [{ id: "x-1", type: "script", code: "alert(1)" }],
});
expect(result.ok).toBe(false);
});
it("必須propの欠落を拒否する", () => {
const result = parseBlockDocument({
schemaVersion: 1,
blocks: [
{
id: "s-1",
type: "stat",
label: "完了したtask",
tone: "neutral",
},
],
});
expect(result.ok).toBe(false);
});
it("長すぎるtextを拒否する", () => {
const result = parseBlockDocument({
schemaVersion: 1,
blocks: [
{
id: "p-1",
type: "paragraph",
text: "a".repeat(601),
},
],
});
expect(result.ok).toBe(false);
});
it("block数の上限を超えたdocumentを拒否する", () => {
const blocks = Array.from({ length: 13 }, (_, index) => ({
id: `p-${index}`,
type: "paragraph" as const,
text: `paragraph ${index}`,
}));
const result = parseBlockDocument({ schemaVersion: 1, blocks });
expect(result.ok).toBe(false);
});
it("未知fieldを拒否する", () => {
const result = parseBlockDocument({
schemaVersion: 1,
blocks: [
{
id: "p-1",
type: "paragraph",
text: "表示用の本文",
html: "<img src=x onerror=alert(1)>",
},
],
});
expect(result.ok).toBe(false);
});
});
このtestはmodelの応答品質を測っていません。appが何を受け入れ、何をrendererへ渡さないかを固定しています。modelやproviderを交換しても残るtestです。
schemaとregistryの対応漏れはruntime testではなくTypeScriptに任せます。BlockSchemaへ新しいtypeを足した状態でtypecheckを走らせ、registryとswitchの両方が更新されるまでcompileを通さない運用にします。
link、image、actionは別々に許可する
3 blockで境界が動いてから、必要なものだけ追加します。自分なら一度に増やしません。
| block | 追加前に決めること |
|---|---|
| link |
https:限定か、許可originを持つか、外部遷移をどう表示するか |
| image | 許可origin、width / height上限、alt、proxyの有無 |
| action | 表示と実行を分離し、権限、確認、二重実行防止をどう持つか |
| nested layout | 最大depth、子要素数、循環を作れないschema |
とくにactionは表示blockの延長で作らないほうが安全です。「削除ボタンを表示できる」と「削除を実行してよい」は別のcontractです。実行側には認可と確認を残します。
imageやlinkを追加しても、modelが返したURLをそのまま信用はしません。Zodで文字列の形を確認したうえで、schemeやoriginのpolicyを通します。remote HTMLを表示したくなったら、通常のblock rendererとは別のsandbox境界として設計します。
modelをつなぐのは最後でいい
fixtureで次の流れが通ったら、modelのstructured outputをBlockDocumentSchemaへ接続します。
unknownを受ける
-> schemaで拒否できる
-> 許可済みcomponentだけを描画する
-> invalidならfallbackを返す
-> failure fixtureで境界を固定する
この順番なら、model SDKやagent runtimeを変えても、schema、registry、fallback、testはapp側に残ります。生成結果の自由度をproviderへ預けていないからです。
生成UIでは、巧妙なpromptより先に「生成してよいblockの一覧」を書きます。自分ならheading、paragraph、statから始めます。それで足りなくなったときに、policyとtestを添えて1種類ずつ増やします。