+
- 1024 × 1536 · png
+ {dims ? `${dims.w} × ${dims.h} · png` : "—"}
{phase === "ready" ? "Tap · anywhere" : "···"}
diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts
index 84ab714..c4b7818 100644
--- a/apps/web/next-env.d.ts
+++ b/apps/web/next-env.d.ts
@@ -1,4 +1,6 @@
///
///
+import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/packages/ai-client/src/chat.ts b/packages/ai-client/src/chat.ts
index 41f4b6d..6e05afd 100644
--- a/packages/ai-client/src/chat.ts
+++ b/packages/ai-client/src/chat.ts
@@ -1,4 +1,5 @@
import type { ProviderConfig } from "@dada/types";
+import { fetchWithRetry } from "./fetchWithRetry";
export type ChatMessage = {
role: "system" | "user" | "assistant";
@@ -20,7 +21,7 @@ export async function chat(
body.response_format = { type: "json_object" };
}
- const res = await fetch(url, {
+ const res = await fetchWithRetry(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
diff --git a/packages/ai-client/src/fetchWithRetry.ts b/packages/ai-client/src/fetchWithRetry.ts
new file mode 100644
index 0000000..3f6531f
--- /dev/null
+++ b/packages/ai-client/src/fetchWithRetry.ts
@@ -0,0 +1,39 @@
+type RetryInit = RequestInit & { retries?: number; retryDelayMs?: number };
+
+export async function fetchWithRetry(
+ url: string,
+ init: RetryInit,
+): Promise {
+ const { retries = 2, retryDelayMs = 1500, ...fetchInit } = init;
+
+ let lastError: unknown;
+ for (let attempt = 0; attempt <= retries; attempt++) {
+ try {
+ const res = await fetch(url, fetchInit);
+ if (res.ok) return res;
+ // Don't retry 4xx (client errors won't fix themselves)
+ if (res.status >= 400 && res.status < 500) return res;
+ // 5xx: retry if we have budget left
+ if (attempt < retries) {
+ await sleep(retryDelayMs * (attempt + 1));
+ continue;
+ }
+ return res;
+ } catch (err) {
+ lastError = err;
+ const isAbort =
+ err instanceof DOMException && err.name === "AbortError";
+ if (isAbort) throw err;
+ if (attempt < retries) {
+ await sleep(retryDelayMs * (attempt + 1));
+ continue;
+ }
+ throw err;
+ }
+ }
+ throw lastError;
+}
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
diff --git a/packages/ai-client/src/image.ts b/packages/ai-client/src/image.ts
index 3296dc3..c87d12b 100644
--- a/packages/ai-client/src/image.ts
+++ b/packages/ai-client/src/image.ts
@@ -1,20 +1,29 @@
import type { ProviderConfig } from "@dada/types";
+import { fetchWithRetry } from "./fetchWithRetry";
+
+type ImageUrlPart = { type: string; image_url?: { url?: string } };
+type ChatResponse = {
+ choices: {
+ message: {
+ content: string | ImageUrlPart[];
+ images?: ImageUrlPart[];
+ };
+ }[];
+};
export async function generateImage(
config: ProviderConfig,
prompt: string,
- opts?: { size?: string; quality?: "low" | "medium" | "high" | "auto" },
): Promise {
- const url = `${config.baseUrl.replace(/\/$/, "")}/images/generations`;
- const body: Record = {
+ const url = `${config.baseUrl.replace(/\/$/, "")}/chat/completions`;
+
+ const body = {
model: config.model,
- prompt,
- size: opts?.size ?? "1024x1536",
- quality: opts?.quality ?? "medium",
- n: 1,
+ modalities: ["image", "text"],
+ messages: [{ role: "user", content: prompt }],
};
- const res = await fetch(url, {
+ const res = await fetchWithRetry(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -25,20 +34,45 @@ export async function generateImage(
if (!res.ok) {
const text = await res.text();
- throw new Error(`Image API error ${res.status}: ${text}`);
+ throw new Error(`Image API error ${res.status}: ${text.slice(0, 500)}`);
}
- const json = (await res.json()) as {
- data: { b64_json?: string; url?: string }[];
- };
- const item = json.data[0];
- if (!item) throw new Error("Image API returned no data");
+ const json = (await res.json()) as ChatResponse;
+ const msg = json.choices[0]?.message;
+ if (!msg) throw new Error("Image API returned no message");
- if (item.b64_json) return item.b64_json;
- if (item.url) {
- const imgRes = await fetch(item.url);
- const buf = await imgRes.arrayBuffer();
- return Buffer.from(buf).toString("base64");
+ // 1) OpenRouter-style: msg.images = [{ image_url: { url } }]
+ // 2) OpenAI multimodal: msg.content = [{ type: "image_url", image_url: { url } }]
+ const structured: ImageUrlPart[] = [];
+ if (msg.images) structured.push(...msg.images);
+ if (Array.isArray(msg.content)) structured.push(...msg.content);
+ for (const part of structured) {
+ const u = part.image_url?.url;
+ if (u) return await urlToBase64(u);
}
- throw new Error("Image API returned neither b64_json nor url");
+
+ // 3) provider-style: content is a string with markdown image 
+ // or a bare URL fragment
+ if (typeof msg.content === "string") {
+ const md = msg.content.match(/!\[[^\]]*\]\((https?:\/\/[^\s)]+)\)/);
+ if (md?.[1]) return await urlToBase64(md[1]);
+ const bare = msg.content.match(/https?:\/\/\S+?\.(?:png|jpg|jpeg|webp)/i);
+ if (bare?.[0]) return await urlToBase64(bare[0]);
+ }
+
+ throw new Error(
+ `No image found in response: ${JSON.stringify(msg).slice(0, 300)}`,
+ );
+}
+
+async function urlToBase64(url: string): Promise {
+ if (url.startsWith("data:")) {
+ const idx = url.indexOf("base64,");
+ if (idx === -1) throw new Error("data URL is not base64-encoded");
+ return url.slice(idx + "base64,".length);
+ }
+ const res = await fetch(url);
+ if (!res.ok) throw new Error(`Failed to fetch image url: ${res.status}`);
+ const buf = await res.arrayBuffer();
+ return Buffer.from(buf).toString("base64");
}
diff --git a/packages/ai-client/src/vision.ts b/packages/ai-client/src/vision.ts
index 0e3c91c..e652190 100644
--- a/packages/ai-client/src/vision.ts
+++ b/packages/ai-client/src/vision.ts
@@ -1,4 +1,5 @@
import type { ProviderConfig } from "@dada/types";
+import { fetchWithRetry } from "./fetchWithRetry";
export async function interpretClick(
config: ProviderConfig,
@@ -25,14 +26,24 @@ export async function interpretClick(
response_format: { type: "json_object" },
};
- const res = await fetch(url, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- Authorization: `Bearer ${config.apiKey}`,
- },
- body: JSON.stringify(body),
- });
+ const timeoutCtrl = new AbortController();
+ const timeoutId = setTimeout(() => timeoutCtrl.abort(), 60_000);
+
+ let res: Response;
+ try {
+ res = await fetchWithRetry(url, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Authorization: `Bearer ${config.apiKey}`,
+ },
+ body: JSON.stringify(body),
+ signal: timeoutCtrl.signal,
+ retries: 0,
+ });
+ } finally {
+ clearTimeout(timeoutId);
+ }
if (!res.ok) {
const text = await res.text();
diff --git a/packages/engine/src/annotate.ts b/packages/engine/src/annotate.ts
index 3f7b412..77df5da 100644
--- a/packages/engine/src/annotate.ts
+++ b/packages/engine/src/annotate.ts
@@ -5,25 +5,31 @@ export async function annotateClick(
click: { x: number; y: number },
): Promise {
const buf = Buffer.from(imageBase64, "base64");
- const meta = await sharp(buf).metadata();
- const w = meta.width ?? 1024;
- const h = meta.height ?? 1536;
+
+ const resized = await sharp(buf)
+ .resize({ width: 768, withoutEnlargement: true, fit: "inside" })
+ .png()
+ .toBuffer();
+
+ const meta = await sharp(resized).metadata();
+ const w = meta.width ?? 768;
+ const h = meta.height ?? 1152;
const cx = Math.round(click.x * w);
const cy = Math.round(click.y * h);
- const r = Math.round(Math.min(w, h) * 0.025);
- const stroke = Math.max(3, Math.round(r * 0.25));
+ const r = Math.max(8, Math.round(Math.min(w, h) * 0.025));
+ const stroke = Math.max(2, Math.round(r * 0.25));
- const svg = `