From 60467a4c05b5f0cdaf2f2c548f5527e07479129e Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Mon, 19 Oct 2026 13:47:33 +0530 Subject: [PATCH] feat: add APIMart AI generation and guided Studio onboarding --- .env.example | 3 + README.md | 43 +++++++ app/lib/studio-ai.ts | 190 +++++++++++++++++++++++++++++++ app/lib/studio-guide.ts | 11 ++ app/middleware/design-studio.ts | 34 +++++- app/types/wrnexus.generated.d.ts | 2 +- deploy/studio-apimart-guide.jpg | Bin 0 -> 103692 bytes scripts/verify-deployment.ts | 16 +++ test/studio-ai.test.ts | 158 +++++++++++++++++++++++++ 9 files changed, 455 insertions(+), 2 deletions(-) create mode 100644 .env.example create mode 100644 app/lib/studio-ai.ts create mode 100644 app/lib/studio-guide.ts create mode 100644 deploy/studio-apimart-guide.jpg create mode 100644 test/studio-ai.test.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..d813777 --- /dev/null +++ b/.env.example @@ -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. diff --git a/README.md b/README.md index c1164d1..e4dae85 100644 --- a/README.md +++ b/README.md @@ -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/`. Create a draft, generate alternatives, open a preview, export JSON, then reload 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. diff --git a/app/lib/studio-ai.ts b/app/lib/studio-ai.ts new file mode 100644 index 0000000..563efbb --- /dev/null +++ b/app/lib/studio-ai.ts @@ -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 => { + 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--; + } + }; +} diff --git a/app/lib/studio-guide.ts b/app/lib/studio-guide.ts new file mode 100644 index 0000000..79687f1 --- /dev/null +++ b/app/lib/studio-guide.ts @@ -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 = `
How to use Design Studio
  1. Describe your website. Open Guided design, enter your product and audience, then choose colors, shapes and spacing with Next.
  2. Generate and compare. Click Generate three directions, review an alternative, then choose Use reviewed proposal.
  3. Refine it. Try “make buttons square and use blue as the primary color” under Edit with words. Lock decisions you want to keep.
  4. Use it in your app. Open Review & apply and export Theme config or Compiled CSS. This website previews and exports designs; it does not change your deployed app automatically.

${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."}

`; + return html + .replace( + '
', + false, + ), + ).toContain("APIMART_API_KEY"); + expect(addStudioGuide('
', 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); +});