feat: add APIMart AI generation and guided Studio onboarding

This commit is contained in:
Clintchiz committed 2026-10-19 13:47:33 +05:30
1 parent 9a6a2e0a8d
commit 60467a4c05
9 files changed
+455 -2

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
# Server only. Never put this key in browser scripts or commit your .env file.
APIMART_API_KEY=
# Design Studio uses the gpt-6-luna model at https://api.apimart.ai/v1/chat/completions.
+43
View File
@@ -106,3 +106,46 @@ A refreshed package-lock.json is retained for npm tooling, but Bun runs the app.
After deployment, check `/`, a component playground and `/design-studio/`. After deployment, check `/`, a component playground and `/design-studio/`.
Create a draft, generate alternatives, open a preview, export JSON, then reload Create a draft, generate alternatives, open a preview, export JSON, then reload
and confirm draft recovery. Existing Studio drafts on other origins remain there. and confirm draft recovery. Existing Studio drafts on other origins remain there.
## APIMart AI generation
Create a `.env` file in the application root on the VM (copy `.env.example`):
```dotenv
APIMART_API_KEY=your_apimart_key
```
Restart the app after setting the key. No key goes in the browser or application
configuration. The server calls https://api.apimart.ai/v1/chat/completions with
model `gpt-6-luna`, bearer authentication, and `stream: false`, as described in
https://docs.apimart.ai/en/api-reference/texts/general/chat-completions.
Model access must be available on your APIMart account. No live provider call is
part of the normal build or test suite.
Open `/design-studio/` and follow the in-page guide:
1. In Guided design, enter what you are building and who will use it. Use Next
to choose tone, geometry, spacing, surfaces and optional brand references.
2. Generate three directions. Review a direction, inspect its changes, then
select Use reviewed proposal to save it as a recoverable draft revision.
3. Under Edit with words, request a change, for example “Make the buttons square,
use blue as the primary color, and reduce shadows.” Lock colors, geometry or
other decisions to keep them unchanged.
4. Preview the design at different sizes and in light/dark mode. Use Components
for individual recipes and Foundations for exact token values.
5. Open Review & apply and export Theme config or Compiled CSS. Integrate one
of those outputs into your application's theme/style configuration. This
hosted Studio does not write to deployed application files.
AI generation sends the current design, brief and allowed component capabilities
to APIMart when you request generation. The engine validates proposals and locks
before offering them for review. Failed requests leave the draft unchanged.
Requests allow two concurrent generations and ten starts per minute per server
process, with two attempts, 4096 output tokens per attempt and a 20-second deadline.
Without APIMART_API_KEY, generation uses local rules and reports that in the UI;
local rules do not interpret arbitrary product descriptions. Drafts remain in
browser storage; export before clearing browser data or moving to another origin.
The complete check includes mocked provider contract/error/lock tests and a
compiled-server deployment check. A real APIMart completion requires your key
and model access; it is not claimed by the mocked tests.
+190
View File
@@ -0,0 +1,190 @@
import type {
GenerationProvider,
GenerationRequest,
} from "@wrnexus/design-studio";
export const APIMART_MODEL = "gpt-6-luna";
export function apimartProvider(
apiKey: string,
transport: typeof fetch = fetch,
): GenerationProvider {
return {
name: "APIMart",
async generate(prompt, options = {}) {
const response = await transport(
"https://api.apimart.ai/v1/chat/completions",
{
method: "POST",
signal: options.signal,
redirect: "error",
headers: {
authorization: `Bearer ${apiKey}`,
"content-type": "application/json",
},
body: JSON.stringify({
model: APIMART_MODEL,
stream: false,
max_tokens: options.maxTokens ?? 4096,
messages: [
{
role: "system",
content:
options.system ?? "Return valid design proposal JSON only.",
},
{ role: "user", content: prompt },
],
}),
},
);
// Never forward upstream bodies: they may echo credentials or submitted context.
if (!response.ok)
throw Error(`APIMart request failed (${response.status}).`);
const raw = await readBounded(response, 200_000);
const result = JSON.parse(raw);
const choice = result.choices?.[0];
if (choice?.finish_reason === "length")
throw Error("APIMart output was truncated.");
if (
typeof choice?.message?.content !== "string" ||
!choice.message.content.trim()
)
throw Error("APIMart returned no design proposal.");
return {
value: choice.message.content,
provider: "APIMart",
model: APIMART_MODEL,
};
},
};
}
async function readBounded(source: Request | Response, limit: number) {
const reader = source.body?.getReader();
const decoder = new TextDecoder();
let text = "",
bytes = 0;
if (!reader) return text;
try {
while (true) {
const chunk = await reader.read();
if (chunk.done) break;
bytes += chunk.value.byteLength;
if (bytes > limit) {
await reader.cancel();
throw Error("Request or response is too large.");
}
text += decoder.decode(chunk.value, { stream: true });
}
return text + decoder.decode();
} finally {
reader.releaseLock();
}
}
export function studioAI(apiKey?: string, transport: typeof fetch = fetch) {
let active = 0,
windowStart = Date.now(),
count = 0;
const json = (value: unknown, status = 200) =>
new Response(JSON.stringify(value), {
status,
headers: {
"content-type": "application/json",
"cache-control": "no-store",
},
});
return async (request: Request): Promise<Response | undefined> => {
const url = new URL(request.url);
if (
!apiKey ||
![
"/design-studio/api/generation",
"/design-studio/api/generate",
].includes(url.pathname)
)
return;
const scheme = request.headers.get("x-forwarded-proto");
if (scheme === "https" || scheme === "http") url.protocol = scheme + ":";
if (
request.headers.get("origin") &&
request.headers.get("origin") !== url.origin
)
return json({ error: "Same-origin requests only." }, 403);
if (url.pathname.endsWith("/generation") && request.method === "GET")
return json({
provider: "APIMart",
model: APIMART_MODEL,
templateVersion: 1,
maxAttempts: 2,
maxOutputTokens: 4096,
timeoutMs: 20000,
});
if (!url.pathname.endsWith("/generate") || request.method !== "POST")
return json({ error: "Method not allowed." }, 405);
if (
!(request.headers.get("content-type") ?? "").startsWith(
"application/json",
)
)
return json({ error: "JSON required." }, 415);
if (Date.now() - windowStart >= 60000) {
windowStart = Date.now();
count = 0;
}
if (active >= 2 || count >= 10)
return json(
{ error: "AI generation is busy. Try again in a minute." },
429,
);
active++;
try {
const input = JSON.parse(
await readBounded(request, 2_000_000),
) as GenerationRequest;
count++;
// Preserve the installed engine's validation, locks, revision checks and repair budget.
const module = import.meta.resolve("@wrnexus/design-studio");
const { generateDesigns } = (await import(
module
)) as typeof import("@wrnexus/design-studio");
const result = await generateDesigns(input, {
provider: apimartProvider(apiKey, transport),
model: APIMART_MODEL,
signal: request.signal,
});
return json(result);
} catch (error) {
const message =
error instanceof Error ? error.message : "Invalid generation request.";
if (message.includes("WRN-STUDIO-CONFLICT"))
return json(
{
error: "Your draft changed. Generate again from the current draft.",
},
409,
);
if (message.includes("too large"))
return json({ error: "Request is too large." }, 413);
if (
request.signal.aborted ||
(error instanceof Error && error.name === "AbortError")
)
return json(
{
error:
"AI generation timed out or was cancelled. Your draft is unchanged.",
},
408,
);
return json(
{
error:
"Could not generate a design. Check the brief and server AI configuration. Your draft is unchanged.",
},
400,
);
} finally {
active--;
}
};
}
+11
View File
@@ -0,0 +1,11 @@
export const studioHelpScript = `window.addEventListener("error",function(){var s=document.getElementById("validation-state"),d=document.getElementById("diagnostics");if(s&&s.textContent==="Checking"){s.textContent="Editor could not start";if(d)d.textContent="Reload the page. If this continues, rebuild and restart the site with the latest Studio configuration. Your saved draft has not been deleted.";}});document.addEventListener("DOMContentLoaded",function(){var b=document.querySelector('[data-tab="guided"]');if(b)b.click();});`;
export function addStudioGuide(html: string, configured: boolean) {
const guide = `<details open class="help"><summary><strong>How to use Design Studio</strong></summary><ol><li><strong>Describe your website.</strong> Open Guided design, enter your product and audience, then choose colors, shapes and spacing with Next.</li><li><strong>Generate and compare.</strong> Click Generate three directions, review an alternative, then choose Use reviewed proposal.</li><li><strong>Refine it.</strong> Try “make buttons square and use blue as the primary color” under Edit with words. Lock decisions you want to keep.</li><li><strong>Use it in your app.</strong> Open Review &amp; apply and export Theme config or Compiled CSS. This website previews and exports designs; it does not change your deployed app automatically.</li></ol><p>${configured ? "APIMart AI is enabled with gpt-6-luna. Generation sends your brief and current design to APIMart only when you request it." : "AI is not configured. Local presets and supported rule-based edits still work. Set APIMART_API_KEY in the server .env and restart to enable gpt-6-luna."}</p></details>`;
return html
.replace(
'<script type="module"',
'<script src="./site-help.js"></script><script type="module"',
)
.replace('<div id="diagnostics"', guide + '<div id="diagnostics"');
}
+33 -1
View File
@@ -1,7 +1,10 @@
import type { Middleware } from "@wrnexus/core"; import type { Middleware } from "@wrnexus/core";
import type { createStudioHandler } from "@wrnexus/design-studio/server"; import type { createStudioHandler } from "@wrnexus/design-studio/server";
import { studioAI } from "../lib/studio-ai.ts";
import { addStudioGuide, studioHelpScript } from "../lib/studio-guide.ts";
let studio: ReturnType<typeof createStudioHandler> | undefined; let studio: ReturnType<typeof createStudioHandler> | undefined;
let ai: ReturnType<typeof studioAI> | undefined;
const designStudio: Middleware = async (ctx, next) => { const designStudio: Middleware = async (ctx, next) => {
const url = new URL(ctx.req.url); const url = new URL(ctx.req.url);
@@ -11,6 +14,19 @@ const designStudio: Middleware = async (ctx, next) => {
headers: { location: "/design-studio/" }, headers: { location: "/design-studio/" },
}); });
if (!url.pathname.startsWith("/design-studio/")) return next(); if (!url.pathname.startsWith("/design-studio/")) return next();
ai ??= studioAI(process.env.APIMART_API_KEY?.trim());
const generated = await ai(ctx.req);
if (generated) return generated;
if (
url.pathname === "/design-studio/site-help.js" &&
ctx.req.method === "GET"
)
return new Response(studioHelpScript, {
headers: {
"content-type": "text/javascript",
"cache-control": "no-store",
},
});
// Load the installed package at runtime so its assets keep their package-relative URLs. // Load the installed package at runtime so its assets keep their package-relative URLs.
const studioModule = import.meta.resolve("@wrnexus/design-studio/server"); const studioModule = import.meta.resolve("@wrnexus/design-studio/server");
studio ??= import(studioModule) studio ??= import(studioModule)
@@ -23,7 +39,23 @@ const designStudio: Middleware = async (ctx, next) => {
studio = undefined; studio = undefined;
throw error; throw error;
}); });
return (await studio)(ctx.req); const response = await (await studio)(ctx.req);
if (
url.pathname === "/design-studio/" &&
response.ok &&
ctx.req.method === "GET"
) {
const headers = new Headers(response.headers);
headers.delete("content-length");
return new Response(
addStudioGuide(
await response.text(),
!!process.env.APIMART_API_KEY?.trim(),
),
{ status: response.status, headers },
);
}
return response;
}; };
export default designStudio; export default designStudio;
+1 -1
View File
@@ -16,7 +16,7 @@ declare namespace WRNexusGenerated {
type RouteName = "accessibility" | "advanced.forms" | "base" | "block.library" | "blocks" | "blocks.announcement.bar" | "blocks.ctasection" | "blocks.feature.card" | "blocks.feature.grid" | "blocks.feature.icon.card" | "blocks.footer" | "blocks.hero" | "blocks.hero.actions" | "blocks.marketing.section.header" | "blocks.metric.grid" | "blocks.page.header" | "blocks.section.header" | "blocks.split.hero" | "blocks.stats.bar" | "colors" | "components.accordion" | "components.advanced.select" | "components.alert" | "components.avatar" | "components.avatar.group" | "components.back.to.top" | "components.badge" | "components.blockquote" | "components.breadcrumb" | "components.button" | "components.button.group" | "components.card" | "components.carousel" | "components.chart" | "components.chat.bubble" | "components.checkbox" | "components.clipboard" | "components.collapse" | "components.color.picker" | "components.columns" | "components.combo.box" | "components.confetti" | "components.container" | "components.context.menu" | "components.custom.scrollbar" | "components.data.map" | "components.data.table" | "components.date.picker" | "components.device.frame" | "components.divider" | "components.drag.and.drop" | "components.drawer" | "components.drawflow.canvas" | "components.dropdown" | "components.file.input" | "components.file.upload.progress" | "components.flow.canvas" | "components.grid" | "components.image" | "components.input" | "components.input.group" | "components.input.number" | "components.kbd" | "components.layout.splitter" | "components.legend.indicator" | "components.link" | "components.list" | "components.list.group" | "components.map" | "components.marquee" | "components.mega.menu" | "components.metric.card" | "components.modal" | "components.nav" | "components.navbar" | "components.pagination" | "components.pin.input" | "components.popover" | "components.preference.switcher" | "components.progress" | "components.radio" | "components.range.slider" | "components.rating" | "components.scrollspy" | "components.search.box" | "components.section" | "components.select" | "components.sidebar" | "components.skeleton" | "components.spinner" | "components.stepper" | "components.strong.password" | "components.styled.icon" | "components.switch" | "components.tabs" | "components.text.link" | "components.textarea" | "components.time.picker" | "components.timeline" | "components.toaster" | "components.toggle.count" | "components.toggle.password" | "components.tooltip" | "components.tree.view" | "components.typography" | "components.wysiwyg.editor" | "core" | "dark.mode" | "data" | "docs" | "fonts" | "forms" | "framework.guides" | "index" | "installation" | "integrations" | "layout" | "marketing" | "navigation" | "overlays" | "preview" | "preview.custom.modal" | "preview.profile" | "preview.project.board" | "preview.scheduling" | "preview.sign.in" | "preview.workspace" | "resources" | "sizing" | "tables" | "templates" | "templates.commerce" | "templates.dashboards" | "templates.marketing" | "templates.productivity" | "themes" | "v06.functions" | "v06.imports" | "v06.migration" | "v06.outputs" | "v06.runtime" | "v06.state" | "v06.stores" | "v06.types"; type RouteName = "accessibility" | "advanced.forms" | "base" | "block.library" | "blocks" | "blocks.announcement.bar" | "blocks.ctasection" | "blocks.feature.card" | "blocks.feature.grid" | "blocks.feature.icon.card" | "blocks.footer" | "blocks.hero" | "blocks.hero.actions" | "blocks.marketing.section.header" | "blocks.metric.grid" | "blocks.page.header" | "blocks.section.header" | "blocks.split.hero" | "blocks.stats.bar" | "colors" | "components.accordion" | "components.advanced.select" | "components.alert" | "components.avatar" | "components.avatar.group" | "components.back.to.top" | "components.badge" | "components.blockquote" | "components.breadcrumb" | "components.button" | "components.button.group" | "components.card" | "components.carousel" | "components.chart" | "components.chat.bubble" | "components.checkbox" | "components.clipboard" | "components.collapse" | "components.color.picker" | "components.columns" | "components.combo.box" | "components.confetti" | "components.container" | "components.context.menu" | "components.custom.scrollbar" | "components.data.map" | "components.data.table" | "components.date.picker" | "components.device.frame" | "components.divider" | "components.drag.and.drop" | "components.drawer" | "components.drawflow.canvas" | "components.dropdown" | "components.file.input" | "components.file.upload.progress" | "components.flow.canvas" | "components.grid" | "components.image" | "components.input" | "components.input.group" | "components.input.number" | "components.kbd" | "components.layout.splitter" | "components.legend.indicator" | "components.link" | "components.list" | "components.list.group" | "components.map" | "components.marquee" | "components.mega.menu" | "components.metric.card" | "components.modal" | "components.nav" | "components.navbar" | "components.pagination" | "components.pin.input" | "components.popover" | "components.preference.switcher" | "components.progress" | "components.radio" | "components.range.slider" | "components.rating" | "components.scrollspy" | "components.search.box" | "components.section" | "components.select" | "components.sidebar" | "components.skeleton" | "components.spinner" | "components.stepper" | "components.strong.password" | "components.styled.icon" | "components.switch" | "components.tabs" | "components.text.link" | "components.textarea" | "components.time.picker" | "components.timeline" | "components.toaster" | "components.toggle.count" | "components.toggle.password" | "components.tooltip" | "components.tree.view" | "components.typography" | "components.wysiwyg.editor" | "core" | "dark.mode" | "data" | "docs" | "fonts" | "forms" | "framework.guides" | "index" | "installation" | "integrations" | "layout" | "marketing" | "navigation" | "overlays" | "preview" | "preview.custom.modal" | "preview.profile" | "preview.project.board" | "preview.scheduling" | "preview.sign.in" | "preview.workspace" | "resources" | "sizing" | "tables" | "templates" | "templates.commerce" | "templates.dashboards" | "templates.marketing" | "templates.productivity" | "themes" | "v06.functions" | "v06.imports" | "v06.migration" | "v06.outputs" | "v06.runtime" | "v06.state" | "v06.stores" | "v06.types";
type ApiRoute = "/api/teams"; type ApiRoute = "/api/teams";
type RealtimeRoute = never; type RealtimeRoute = never;
type EnvironmentKey = never; type EnvironmentKey = "APIMART_API_KEY";
type TranslationKey = never; type TranslationKey = never;
type QueueName = never; type QueueName = never;
type CacheKey = never; type CacheKey = never;
Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

+16
View File
@@ -32,9 +32,15 @@ try {
"/design-studio/", "/design-studio/",
"/design-studio/studio.js", "/design-studio/studio.js",
"/design-studio/studio.css", "/design-studio/studio.css",
"/design-studio/site-help.js",
]) { ]) {
const response = await fetch(origin + path); const response = await fetch(origin + path);
if (!response.ok) throw Error(`${path} returned ${response.status}`); if (!response.ok) throw Error(`${path} returned ${response.status}`);
if (
path === "/design-studio/" &&
!(await response.clone().text()).includes("How to use Design Studio")
)
throw Error("Studio's usage guide was not included in the compiled app.");
const policy = response.headers.get("content-security-policy") ?? ""; const policy = response.headers.get("content-security-policy") ?? "";
if (path === "/" && !policy.includes("require-trusted-types-for")) if (path === "/" && !policy.includes("require-trusted-types-for"))
throw Error( throw Error(
@@ -50,6 +56,16 @@ try {
const application = await ( const application = await (
await fetch(origin + "/design-studio/api/application") await fetch(origin + "/design-studio/api/application")
).json(); ).json();
const generation = await (
await fetch(origin + "/design-studio/api/generation")
).json();
if (
process.env.APIMART_API_KEY?.trim() &&
(generation.provider !== "APIMart" || generation.model !== "gpt-6-luna")
)
throw Error(
"The compiled app did not pick up its server-side AI configuration.",
);
if (application.enabled !== false) if (application.enabled !== false)
throw Error("Hosted Studio must remain export-only."); throw Error("Hosted Studio must remain export-only.");
const preview = await fetch(origin + "/design-studio/api/preview", { const preview = await fetch(origin + "/design-studio/api/preview", {
+158
View File
@@ -0,0 +1,158 @@
import { expect, test } from "bun:test";
import { CORE_DESIGN_SYSTEMS } from "@wrnexus/design-system";
import { designRevision, DEFAULT_DESIGN_BRIEF } from "@wrnexus/design-studio";
import { apimartProvider, studioAI } from "../app/lib/studio-ai.ts";
import { addStudioGuide } from "../app/lib/studio-guide.ts";
const proposals = ["2px", "4px", "8px"].map((value, index) => ({
name: `Direction ${index + 1}`,
rationale: "A different control shape.",
patch: [{ op: "set", path: "/tokens/shape.control/value", value }],
}));
const completion = () =>
Response.json({
choices: [
{
finish_reason: "stop",
message: { content: JSON.stringify({ alternatives: proposals }) },
},
],
});
const mockFetch = (
fn: (input: string | URL | Request, init?: RequestInit) => Promise<Response>,
) => fn as typeof fetch;
const system = CORE_DESIGN_SYSTEMS.square;
const input = async (locks: string[] = []) => ({
system,
locks,
brief: {
...DEFAULT_DESIGN_BRIEF,
product: "A website",
audience: "Customers",
},
kind: "directions",
baseRevision: await designRevision(system, locks),
});
const request = async (body?: unknown, origin = "https://components.example") =>
new Request("https://components.example/design-studio/api/generate", {
method: "POST",
headers: { "content-type": "application/json", origin },
body: JSON.stringify(body ?? (await input())),
});
test("APIMart uses the specified model, bearer authentication, non-streaming JSON and cancellation", async () => {
const controller = new AbortController();
const provider = apimartProvider(
"test-secret",
mockFetch(async (url, init) => {
expect(String(url)).toBe("https://api.apimart.ai/v1/chat/completions");
expect(new Headers(init?.headers).get("authorization")).toBe(
"Bearer test-secret",
);
expect(init?.signal).toBe(controller.signal);
const body = JSON.parse(init?.body as string);
expect(body.model).toBe("gpt-6-luna");
expect(body.stream).toBe(false);
expect(body.max_tokens).toBe(4096);
expect(body.messages).toEqual([
{ role: "system", content: "rules" },
{ role: "user", content: "brief" },
]);
return completion();
}),
);
const result = await provider.generate("brief", {
system: "rules",
signal: controller.signal,
});
expect(JSON.parse(result.value).alternatives).toHaveLength(3);
});
test("AI proposals use engine validation, preserve draft source, locks and revision checks", async () => {
let calls = 0;
const handler = studioAI(
"test-secret",
mockFetch(async () => {
calls++;
return completion();
}),
);
const original = JSON.stringify(system);
const response = await handler(await request());
const batch = await response!.json();
expect(batch.alternatives).toHaveLength(3);
expect(batch.provenance.provider).toBe("APIMart");
expect(batch.provenance.model).toBe("gpt-6-luna");
expect(JSON.stringify(system)).toBe(original);
const locked = await handler(
await request(await input(["/tokens/shape.control"])),
);
expect((await locked!.json()).alternatives).toHaveLength(0);
const before = calls;
expect(
(await handler(
await request({ ...(await input()), baseRevision: "a".repeat(64) }),
))!.status,
).toBe(409);
expect(calls).toBe(before);
});
test("metadata and errors never contain the key or raw upstream response", async () => {
const handler = studioAI(
"test-secret",
mockFetch(
async () =>
new Response("test-secret upstream credentials", { status: 401 }),
),
);
const meta = await handler(
new Request("https://components.example/design-studio/api/generation"),
);
const text = await meta!.text();
expect(text).toContain("gpt-6-luna");
expect(text).not.toContain("test-secret");
const failure = await handler(await request());
const body = await failure!.text();
expect(body).not.toContain("test-secret");
expect(body).not.toContain("upstream credentials");
expect(body).toContain("WRN-STUDIO-PROVIDER");
});
test("same-origin checks, body bounds and local fallback remain enforced", async () => {
let calls = 0;
const handler = studioAI(
"test-secret",
mockFetch(async () => {
calls++;
return completion();
}),
);
expect(
(await handler(await request(await input(), "https://other.example")))!
.status,
).toBe(403);
expect(
(await handler(await request({ huge: "x".repeat(2_000_001) })))!.status,
).toBe(413);
expect(calls).toBe(0);
expect(await studioAI()(await request())).toBeUndefined();
expect(
addStudioGuide(
'<head><script type="module"></script></head><div id="diagnostics"></div>',
false,
),
).toContain("APIMART_API_KEY");
expect(addStudioGuide('<div id="diagnostics"></div>', true)).toContain(
"APIMart AI is enabled",
);
});
test("a public AI endpoint enforces a bounded generation rate", async () => {
const handler = studioAI(
"test-secret",
mockFetch(async () => completion()),
);
for (let index = 0; index < 10; index++)
expect((await handler(await request()))!.status).toBe(200);
expect((await handler(await request()))!.status).toBe(429);
});