feat: add APIMart AI generation and guided Studio onboarding
This commit is contained in:
1 parent
9a6a2e0a8d
commit
60467a4c05
9 files changed
+455
-2
No files matched your search
@@ -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--;
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user